🪞 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

This commit is contained in:
Danny Avila 2026-06-08 17:14:21 -04:00 committed by GitHub
parent ae0c187ddd
commit 2a956f143d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 479 additions and 26 deletions

View file

@ -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',
}),

View file

@ -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,
});

View file

@ -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,

View file

@ -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,

View file

@ -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}

View file

@ -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',
}),
);

View file

@ -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 = {

View file

@ -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();

View file

@ -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 = {

View file

@ -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:

View file

@ -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;
}

View file

@ -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);
}

View 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();
});
});

View file

@ -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,
};

View file

@ -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);

View file

@ -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',
}),
}),
);

View file

@ -148,6 +148,8 @@ export interface ResumeState {
responseMessageId?: string;
conversationId?: string;
sender?: string;
iconURL?: string;
model?: string;
titleEvent?: {
event: 'title';
data?: {

View file

@ -217,6 +217,8 @@ export namespace Agents {
responseMessageId?: string;
conversationId?: string;
sender?: string;
iconURL?: string;
model?: string;
titleEvent?: {
event: 'title';
data?: {