diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
index 0b4214055a..c30d5e07be 100644
--- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -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',
}),
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
index 6c254f061a..24b1247546 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -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,
});
diff --git a/api/server/routes/agents/__tests__/abort.spec.js b/api/server/routes/agents/__tests__/abort.spec.js
index f157528371..418c5f4254 100644
--- a/api/server/routes/agents/__tests__/abort.spec.js
+++ b/api/server/routes/agents/__tests__/abort.spec.js
@@ -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,
diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js
index 09bfb2a144..db5976bb38 100644
--- a/api/server/routes/agents/index.js
+++ b/api/server/routes/agents/index.js
@@ -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,
diff --git a/client/src/components/Conversations/ProjectsSection.tsx b/client/src/components/Conversations/ProjectsSection.tsx
index 9d89fad8d2..23c19f4dde 100644
--- a/client/src/components/Conversations/ProjectsSection.tsx
+++ b/client/src/components/Conversations/ProjectsSection.tsx
@@ -542,7 +542,11 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
/>
- {isExpanded &&
{renderProjectsBody()}
}
+ {isExpanded && (
+
+ {renderProjectsBody()}
+
+ )}
{
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',
}),
);
diff --git a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
index 8b5354391d..fdb6d79c9f 100644
--- a/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
+++ b/client/src/hooks/SSE/__tests__/useResumeOnLoad.spec.tsx
@@ -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 = [];
+ 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 = {
diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts
index 084eb6ab2c..43ae4d2336 100644
--- a/client/src/hooks/SSE/useResumableSSE.ts
+++ b/client/src/hooks/SSE/useResumableSSE.ts
@@ -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();
diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts
index 739d4a818a..97c1bc91c0 100644
--- a/client/src/hooks/SSE/useResumeOnLoad.ts
+++ b/client/src/hooks/SSE/useResumeOnLoad.ts
@@ -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 = {
diff --git a/e2e/config/librechat.e2e.yaml b/e2e/config/librechat.e2e.yaml
index 3d42915d7f..4ed7224f53 100644
--- a/e2e/config/librechat.e2e.yaml
+++ b/e2e/config/librechat.e2e.yaml
@@ -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:
diff --git a/e2e/setup/fake-model.js b/e2e/setup/fake-model.js
index 5b6a58191c..277adc85cc 100644
--- a/e2e/setup/fake-model.js
+++ b/e2e/setup/fake-model.js
@@ -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;
}
diff --git a/e2e/specs/mock/helpers.ts b/e2e/specs/mock/helpers.ts
index 2ccae83ce2..08538b28d6 100644
--- a/e2e/specs/mock/helpers.ts
+++ b/e2e/specs/mock/helpers.ts
@@ -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);
}
diff --git a/e2e/specs/mock/model-spec-icons.spec.ts b/e2e/specs/mock/model-spec-icons.spec.ts
new file mode 100644
index 0000000000..64b861bbfd
--- /dev/null
+++ b/e2e/specs/mock/model-spec-icons.spec.ts
@@ -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();
+ });
+});
diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts
index 931ec62c0a..238db4e6cb 100644
--- a/packages/api/src/stream/GenerationJobManager.ts
+++ b/packages/api/src/stream/GenerationJobManager.ts
@@ -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,
};
diff --git a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts
index a994f6c11b..3e34d155d5 100644
--- a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts
+++ b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts
@@ -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);
diff --git a/packages/api/src/stream/__tests__/collectedUsage.spec.ts b/packages/api/src/stream/__tests__/collectedUsage.spec.ts
index 0c815b4eb4..30f70c2090 100644
--- a/packages/api/src/stream/__tests__/collectedUsage.spec.ts
+++ b/packages/api/src/stream/__tests__/collectedUsage.spec.ts
@@ -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',
}),
}),
);
diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts
index 17dca7b943..3e6786a49b 100644
--- a/packages/api/src/stream/interfaces/IJobStore.ts
+++ b/packages/api/src/stream/interfaces/IJobStore.ts
@@ -148,6 +148,8 @@ export interface ResumeState {
responseMessageId?: string;
conversationId?: string;
sender?: string;
+ iconURL?: string;
+ model?: string;
titleEvent?: {
event: 'title';
data?: {
diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts
index 7e2408c962..0eab753bd3 100644
--- a/packages/data-provider/src/types/agents.ts
+++ b/packages/data-provider/src/types/agents.ts
@@ -217,6 +217,8 @@ export namespace Agents {
responseMessageId?: string;
conversationId?: string;
sender?: string;
+ iconURL?: string;
+ model?: string;
titleEvent?: {
event: 'title';
data?: {