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?: {