Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
367 changes: 350 additions & 17 deletions api/server/controllers/agents/__tests__/responses.unit.spec.js

Large diffs are not rendered by default.

256 changes: 162 additions & 94 deletions api/server/controllers/agents/responses.js

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions packages/api/src/agents/responses/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,32 @@ describe('response usage aggregation', () => {
expect(response.usage).toEqual(usage);
});

it('prepares final text without publishing completion until storage succeeds', () => {
const writes: string[] = [];
const res = {
write: (chunk: string) => {
writes.push(chunk);
},
} as unknown as ServerResponse;
const tracker = createResponseTracker();
const { handlers, prepareStream, completeStream } = createResponsesEventHandlers({
res,
context,
tracker,
});
handlers.on_message_delta.handle('on_message_delta', {
delta: { content: [{ type: 'text', text: 'saved answer' }] },
});
prepareStream();
expect(buildResponse(context, tracker, 'completed').output).toEqual([
expect.objectContaining({ content: [expect.objectContaining({ text: 'saved answer' })] }),
]);
expect(writes.join('')).not.toContain('response.completed');
completeStream();
expect(writes.join('')).toContain('response.completed');
expect(writes[writes.length - 1]).toBe('data: [DONE]\n\n');
});

it('uses the normalized override in the completed streaming event', () => {
const writes: string[] = [];
const res = {
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/agents/responses/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export {
updateTrackerUsage,
} from './handlers';

export { resolveStoredResponse, selectStoredResponseHistory } from './storage';

// Service
export {
// Validation
Expand All @@ -170,6 +172,7 @@ export {
sendResponsesErrorResponse,
// Context
generateResponseId,
RESPONSE_ID_PREFIX,
createResponseContext,
// Streaming setup
setupStreamingResponse,
Expand Down
16 changes: 13 additions & 3 deletions packages/api/src/agents/responses/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,10 @@ export function sendResponsesErrorResponse(
/**
* Generate a unique response ID
*/
export const RESPONSE_ID_PREFIX = 'resp_';

export function generateResponseId(): string {
return `resp_${Date.now().toString(36)}${Math.random().toString(36).substring(2, 8)}`;
return `${RESPONSE_ID_PREFIX}${Date.now().toString(36)}${Math.random().toString(36).substring(2, 8)}`;
}

/**
Expand Down Expand Up @@ -625,6 +627,8 @@ export function createResponsesEventHandlers(config: StreamHandlerConfig): {
handlers: Record<string, { handle: (event: string, data: unknown) => void }>;
state: StreamState;
finalizeStream: (usage?: Usage) => void;
prepareStream: () => void;
completeStream: (usage?: Usage) => void;
emitClientToolDeferral: (callId: string, output: string) => void;
} {
const state: StreamState = {
Expand Down Expand Up @@ -955,19 +959,25 @@ export function createResponsesEventHandlers(config: StreamHandlerConfig): {
/**
* Finalize the stream - close open items and emit completed
*/
const finalizeStream = (usage?: Usage): void => {
const prepareStream = (): void => {
closeOpenStreams();
for (const [callId, output] of pendingClientToolDeferrals ?? []) {
deliverClientToolDeferral(callId, output);
}
// A later step can announce a sibling before its arguments or deferral
// are settled. Only terminate handoffs after the run has completed.
closeOpenClientToolCalls();
};
const completeStream = (usage?: Usage): void => {
emitResponseCompleted(config, usage);
writeDone(config.res);
};
const finalizeStream = (usage?: Usage): void => {
prepareStream();
completeStream(usage);
};

return { handlers, state, finalizeStream, emitClientToolDeferral };
return { handlers, state, finalizeStream, prepareStream, completeStream, emitClientToolDeferral };
}

/* =============================================================================
Expand Down
74 changes: 74 additions & 0 deletions packages/api/src/agents/responses/storage.mongo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import mongoose from 'mongoose';
import { EModelEndpoint } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createMethods, createModels } from '@librechat/data-schemas';
import { resolveStoredResponse } from './storage';

const conversationId = '10000000-0000-4000-8000-000000000001';
const owner = 'owner-user';

let mongod: MongoMemoryServer;
let db: ReturnType<typeof createMethods>;

beforeAll(async () => {
mongod = await MongoMemoryServer.create({ instance: { args: ['--nounixsocket'] } });
await mongoose.connect(mongod.getUri());
createModels(mongoose);
db = createMethods(mongoose);
});

afterAll(async () => {
await mongoose.disconnect();
if (mongod) await mongod.stop();
});

it('stores a response under an independent message ID and admits it only after the conversation commit', async () => {
const context = { userId: owner };
const input = await db.saveMessage(context, {
messageId: 'input-one',
conversationId,
isCreatedByUser: true,
text: 'first',
sender: 'User',
endpoint: EModelEndpoint.agents,
});
expect(input).toBeTruthy();
const pending = await db.saveMessage(context, {
messageId: 'resp_one',
conversationId,
isCreatedByUser: false,
text: 'first answer',
sender: 'Agent',
endpoint: EModelEndpoint.agents,
finish_reason: 'pending_storage',
});
expect(pending).toBeTruthy();
expect(await resolveStoredResponse(owner, 'resp_one', db)).toBeNull();

const conversation = await db.saveConvo(context, {
conversationId,
agent_id: 'agent-one',
endpoint: EModelEndpoint.agents,
});
expect(conversation).toBeTruthy();
expect((await db.getConvo(owner, conversationId))?.messages).toHaveLength(2);
expect(await resolveStoredResponse(owner, 'resp_one', db)).toBeNull();

await db.updateMessage(owner, { messageId: 'resp_one', finish_reason: 'stop' });
const stored = await resolveStoredResponse(owner, 'resp_one', db);
expect(stored?.conversation.conversationId).toBe(conversationId);
expect(stored?.message?.text).toBe('first answer');
expect(await resolveStoredResponse('another-user', 'resp_one', db)).toBeNull();
expect(await resolveStoredResponse(owner, 'resp_missing', db)).toBeNull();

await db.saveMessage(context, {
messageId: 'resp_failed',
conversationId,
isCreatedByUser: false,
text: 'uncommitted',
sender: 'Agent',
endpoint: EModelEndpoint.agents,
finish_reason: 'pending_storage',
});
expect(await resolveStoredResponse(owner, 'resp_failed', db)).toBeNull();
});
80 changes: 80 additions & 0 deletions packages/api/src/agents/responses/storage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { EModelEndpoint } from 'librechat-data-provider';
import { resolveStoredResponse, selectStoredResponseHistory } from './storage';

const responseMessage = {
messageId: 'resp_one',
conversationId: 'conversation-one',
endpoint: EModelEndpoint.agents,
sender: 'Agent',
isCreatedByUser: false,
finish_reason: 'stop',
};
const conversation = { conversationId: 'conversation-one', user: 'owner' };

function reads() {
return {
getMessage: jest.fn().mockResolvedValue(responseMessage),
getConvo: jest.fn().mockResolvedValue(conversation),
};
}

describe('stored Responses ID resolution', () => {
it('looks up a response by its owner-scoped indexed message, then verifies its conversation', async () => {
const db = reads();
expect(await resolveStoredResponse('owner', 'resp_one', db)).toEqual({
message: responseMessage,
conversation,
});
expect(db.getMessage).toHaveBeenCalledWith({ user: 'owner', messageId: 'resp_one' });
expect(db.getConvo).toHaveBeenCalledWith('owner', 'conversation-one');
});

it('does not accept unstored, foreign, non-assistant, or deleted-conversation response IDs', async () => {
const db = reads();
for (const message of [
null,
{ ...responseMessage, isCreatedByUser: true },
{ ...responseMessage, endpoint: 'openAI' },
{ ...responseMessage, sender: 'User' },
{ ...responseMessage, finish_reason: 'pending_storage' },
{ ...responseMessage, conversationId: '' },
]) {
db.getMessage.mockResolvedValueOnce(message);
expect(await resolveStoredResponse('owner', 'resp_one', db)).toBeNull();
}
db.getMessage.mockResolvedValueOnce(responseMessage);
db.getConvo.mockResolvedValueOnce(null);
expect(await resolveStoredResponse('owner', 'resp_one', db)).toBeNull();
db.getMessage.mockResolvedValueOnce(responseMessage);
db.getConvo.mockResolvedValueOnce({ conversationId: 'different' });
expect(await resolveStoredResponse('owner', 'resp_one', db)).toBeNull();
});

it('replays only through the referenced response and excludes incomplete writes', () => {
const messages = [
{ messageId: 'user-one' },
{ messageId: 'resp_one', finish_reason: 'stop' },
{ messageId: 'resp_failed', finish_reason: 'pending_storage' },
{ messageId: 'user-two' },
{ messageId: 'resp_two', finish_reason: 'stop' },
];
expect(selectStoredResponseHistory(messages, 'resp_one')).toEqual(messages.slice(0, 2));
expect(selectStoredResponseHistory(messages, 'resp_two')).toEqual([
messages[0],
messages[1],
messages[3],
messages[4],
]);
expect(selectStoredResponseHistory(messages, 'resp_missing')).toEqual([]);
});

it('keeps the existing authorized conversation-ID form without a guaranteed-miss message read', async () => {
const db = reads();
expect(await resolveStoredResponse('owner', 'conversation-one', db)).toEqual({
message: null,
conversation,
});
expect(db.getMessage).not.toHaveBeenCalled();
expect(db.getConvo).toHaveBeenCalledWith('owner', 'conversation-one');
});
});
71 changes: 71 additions & 0 deletions packages/api/src/agents/responses/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { EModelEndpoint } from 'librechat-data-provider';
import { RESPONSE_ID_PREFIX } from './service';

/** Only persisted Responses assistant messages can name a continued run. */
export interface StoredResponseMessage {
messageId: string;
conversationId: string;
endpoint?: string;
isCreatedByUser?: boolean;
sender?: string;
finish_reason?: string;
text?: string;
tokenCount?: number;
createdAt?: Date;
updatedAt?: Date;
}

export interface StoredResponseConversation {
conversationId: string;
}

/** The referenced response bounds history; an incomplete store is never model input. */
export function selectStoredResponseHistory<
Message extends { messageId?: string; finish_reason?: string },
>(messages: Message[], responseId?: string): Message[] {
const lastIndex = responseId
? messages.findIndex((message) => message.messageId === responseId)
: -1;
if (responseId && lastIndex < 0) return [];
return (responseId ? messages.slice(0, lastIndex + 1) : messages).filter(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconstruct the response ancestry instead of slicing history

When a client branches from an older response, this chronological slice includes unrelated sibling turns. For example, after resp_1 → resp_2, creating resp_3 from resp_1 stores all three in one conversation; subsequently continuing resp_3 slices through resp_2, so the model receives the sibling user prompt and answer even though they are not ancestors of resp_3. Persist and traverse the previous_response_id relationship, or otherwise isolate branches, rather than treating every earlier conversation message as history.

Useful? React with 👍 / 👎.

(message) =>
!message.messageId?.startsWith(RESPONSE_ID_PREFIX) || message.finish_reason === 'stop',
Comment on lines +30 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the entire turn when its storage commit fails

If saveConversation or the final updateMessage fails after the input was saved, the request reports response_storage_failed, but this filter removes only the pending_storage assistant row and retains the associated user messages. A later continuation via the supported legacy conversation ID—and any later response-ID continuation created after that—therefore sends the supposedly failed prompt to the model. The commit protocol needs to roll back the input or tag every message in the turn so history selection can exclude the whole uncommitted turn.

Useful? React with 👍 / 👎.

);
}

export interface StoredResponseReads<
Message extends StoredResponseMessage,
Conversation extends StoredResponseConversation,
> {
getMessage: (params: { user: string; messageId: string }) => Promise<Message | null>;
getConvo: (user: string, conversationId: string) => Promise<Conversation | null>;
}

/** A response ID is the assistant message ID; legacy conversation IDs remain supported. */
export async function resolveStoredResponse<
Message extends StoredResponseMessage,
Conversation extends StoredResponseConversation,
>(
user: string,
id: string,
reads: StoredResponseReads<Message, Conversation>,
): Promise<{ conversation: Conversation; message: Message | null } | null> {
if (!id.startsWith(RESPONSE_ID_PREFIX)) {
const conversation = await reads.getConvo(user, id);
return conversation ? { conversation, message: null } : null;
}

const message = await reads.getMessage({ user, messageId: id });
if (
message?.messageId !== id ||
message.isCreatedByUser !== false ||
message.endpoint !== EModelEndpoint.agents ||
message.sender !== 'Agent' ||
message.finish_reason !== 'stop' ||
!message.conversationId
) {
return null;
}
const conversation = await reads.getConvo(user, message.conversationId);
return conversation?.conversationId === message.conversationId ? { conversation, message } : null;
}
Loading