Replace compaction notifications with system notifications (#5218)
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Message, ConversationCompacted } from '../../api';
|
||||
|
||||
interface CompactionMarkerProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export const CompactionMarker: React.FC<CompactionMarkerProps> = ({ message }) => {
|
||||
const compactionContent = message.content.find(
|
||||
(content): content is ConversationCompacted & { type: 'conversationCompacted' } =>
|
||||
content.type === 'conversationCompacted'
|
||||
);
|
||||
|
||||
const markerText = compactionContent?.msg || 'Conversation compacted';
|
||||
|
||||
return <div className="text-xs text-gray-400 py-2 text-left">{markerText}</div>;
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { manageContextFromBackend } from './index';
|
||||
import { Message } from '../../api';
|
||||
|
||||
// Define the context management interface
|
||||
interface ContextManagerState {
|
||||
isCompacting: boolean;
|
||||
compactionError: string | null;
|
||||
}
|
||||
|
||||
interface ContextManagerActions {
|
||||
handleManualCompaction: (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append?: (message: Message) => void,
|
||||
sessionId?: string
|
||||
) => Promise<void>;
|
||||
hasCompactionMarker: (message: Message) => boolean;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
const ContextManagerContext = createContext<
|
||||
(ContextManagerState & ContextManagerActions) | undefined
|
||||
>(undefined);
|
||||
|
||||
// Create the provider component
|
||||
export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isCompacting, setIsCompacting] = useState<boolean>(false);
|
||||
const [compactionError, setCompactionError] = useState<string | null>(null);
|
||||
|
||||
const performCompaction = useCallback(
|
||||
async (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append: (message: Message) => void,
|
||||
sessionId: string,
|
||||
isManual: boolean = false
|
||||
) => {
|
||||
setIsCompacting(true);
|
||||
setCompactionError(null);
|
||||
|
||||
try {
|
||||
// Get the summary from the backend
|
||||
const summaryResponse = await manageContextFromBackend({
|
||||
messages: messages,
|
||||
manageAction: 'summarize',
|
||||
sessionId: sessionId,
|
||||
});
|
||||
|
||||
setMessages(summaryResponse.messages);
|
||||
|
||||
// Only automatically submit the continuation message for auto-compaction (context limit reached)
|
||||
// Manual compaction should just compact without continuing the conversation
|
||||
if (!isManual) {
|
||||
// Automatically submit the continuation message to continue the conversation
|
||||
// This should be the third message (index 2) which contains the "I ran into a context length exceeded error..." text
|
||||
const continuationMessage = summaryResponse.messages[2];
|
||||
if (continuationMessage) {
|
||||
setTimeout(() => {
|
||||
append(continuationMessage);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
setIsCompacting(false);
|
||||
} catch (err) {
|
||||
// TODO(Douwe): move this to the server
|
||||
console.error('Error during compaction:', err);
|
||||
setCompactionError(err instanceof Error ? err.message : 'Unknown error during compaction');
|
||||
|
||||
// Create an error marker
|
||||
const errorMarker: Message = {
|
||||
id: `compaction-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
{
|
||||
type: 'conversationCompacted',
|
||||
msg: 'Compaction failed. Please try again or start a new session.',
|
||||
},
|
||||
],
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
};
|
||||
|
||||
setMessages([...messages, errorMarker]);
|
||||
setIsCompacting(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleManualCompaction = useCallback(
|
||||
async (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void,
|
||||
append?: (message: Message) => void,
|
||||
sessionId?: string
|
||||
) => {
|
||||
await performCompaction(messages, setMessages, append || (() => {}), sessionId || '', true);
|
||||
},
|
||||
[performCompaction]
|
||||
);
|
||||
|
||||
const hasCompactionMarker = useCallback((message: Message): boolean => {
|
||||
return message.content.some((content) => content.type === 'conversationCompacted');
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
// State
|
||||
isCompacting,
|
||||
compactionError,
|
||||
|
||||
// Actions
|
||||
handleManualCompaction,
|
||||
hasCompactionMarker,
|
||||
};
|
||||
|
||||
return <ContextManagerContext.Provider value={value}>{children}</ContextManagerContext.Provider>;
|
||||
};
|
||||
|
||||
// Create a hook to use the context
|
||||
export const useContextManager = () => {
|
||||
const context = useContext(ContextManagerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useContextManager must be used within a ContextManagerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Message, SystemNotificationContent } from '../../api';
|
||||
|
||||
interface SystemNotificationInlineProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export const SystemNotificationInline: React.FC<SystemNotificationInlineProps> = ({ message }) => {
|
||||
const systemNotification = message.content.find(
|
||||
(content): content is SystemNotificationContent & { type: 'systemNotification' } =>
|
||||
content.type === 'systemNotification' && content.notificationType === 'inlineMessage'
|
||||
);
|
||||
|
||||
if (!systemNotification?.msg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className="text-xs text-gray-400 py-2 text-left">{systemNotification.msg}</div>;
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CompactionMarker } from '../CompactionMarker';
|
||||
import { Message } from '../../../api';
|
||||
|
||||
const default_message: Message = {
|
||||
metadata: {
|
||||
agentVisible: false,
|
||||
userVisible: false,
|
||||
},
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,content: []
|
||||
};
|
||||
|
||||
describe('CompactionMarker', () => {
|
||||
it('should render default message when no conversationCompacted content found', () => {
|
||||
const message: Message = {
|
||||
...default_message,
|
||||
content: [{ type: 'text', text: 'Regular message' }],
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom message from conversationCompacted content', () => {
|
||||
const message: Message = {
|
||||
...default_message,
|
||||
content: [
|
||||
{ type: 'text', text: 'Some other content' },
|
||||
{ type: 'conversationCompacted', msg: 'Custom compaction message' },
|
||||
],
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
expect(screen.getByText('Custom compaction message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty message content array', () => {
|
||||
const message: Message = {
|
||||
...default_message,
|
||||
content: [],
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle summarizationRequested content with empty msg', () => {
|
||||
const message: Message = {
|
||||
...default_message,
|
||||
content: [{ type: 'conversationCompacted', msg: '' }],
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
// Empty string falls back to default due to || operator
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle summarizationRequested content with undefined msg', () => {
|
||||
const message: Message = {
|
||||
...default_message,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content: [{ type: 'conversationCompacted' } as any],
|
||||
};
|
||||
|
||||
render(<CompactionMarker message={message} />);
|
||||
|
||||
// Should render the default message when msg is undefined
|
||||
expect(screen.getByText('Conversation compacted')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { ContextManagerProvider, useContextManager } from '../ContextManager';
|
||||
import * as contextManagement from '../index';
|
||||
import { Message } from '../../../api';
|
||||
|
||||
const default_message: Message = {
|
||||
metadata: {
|
||||
agentVisible: false,
|
||||
userVisible: false,
|
||||
},
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
created: 1000,
|
||||
content: [],
|
||||
};
|
||||
|
||||
// Mock the context management functions
|
||||
vi.mock('../index', () => ({
|
||||
manageContextFromBackend: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend);
|
||||
|
||||
describe('ContextManager', () => {
|
||||
const mockMessages: Message[] = [
|
||||
{
|
||||
...default_message,
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
...default_message,
|
||||
content: [{ type: 'text', text: 'Hi there!' }],
|
||||
},
|
||||
];
|
||||
|
||||
const mockSetMessages = vi.fn();
|
||||
const mockAppend = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const renderContextManager = () => {
|
||||
return renderHook(() => useContextManager(), {
|
||||
wrapper: ({ children }) => <ContextManagerProvider>{children}</ContextManagerProvider>,
|
||||
});
|
||||
};
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should have correct initial state', () => {
|
||||
const { result } = renderContextManager();
|
||||
|
||||
expect(result.current.isCompacting).toBe(false);
|
||||
expect(result.current.compactionError).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCompactionMarker', () => {
|
||||
it('should return true for messages with summarizationRequested content', () => {
|
||||
const { result } = renderContextManager();
|
||||
const messageWithMarker: Message = {
|
||||
...default_message,
|
||||
content: [{ type: 'conversationCompacted', msg: 'Compaction marker' }],
|
||||
};
|
||||
|
||||
expect(result.current.hasCompactionMarker(messageWithMarker)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for messages without summarizationRequested content', () => {
|
||||
const { result } = renderContextManager();
|
||||
const regularMessage: Message = {
|
||||
...default_message,
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
};
|
||||
|
||||
expect(result.current.hasCompactionMarker(regularMessage)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for messages with mixed content including conversationCompacted', () => {
|
||||
const { result } = renderContextManager();
|
||||
const mixedMessage: Message = {
|
||||
...default_message,
|
||||
content: [
|
||||
{ type: 'text', text: 'Some text' },
|
||||
{ type: 'conversationCompacted', msg: 'Compaction marker' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(result.current.hasCompactionMarker(mixedMessage)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleManualCompaction', () => {
|
||||
it('should perform compaction with server-provided messages', async () => {
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
...default_message,
|
||||
content: [
|
||||
{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...default_message,
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
},
|
||||
{
|
||||
...default_message,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
'test-session-id'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockManageContextFromBackend).toHaveBeenCalledWith({
|
||||
messages: mockMessages,
|
||||
manageAction: 'summarize',
|
||||
sessionId: 'test-session-id',
|
||||
});
|
||||
|
||||
// Verify all three messages are set
|
||||
expect(mockSetMessages).toHaveBeenCalledTimes(1);
|
||||
const setMessagesCall = mockSetMessages.mock.calls[0][0];
|
||||
expect(setMessagesCall).toHaveLength(3);
|
||||
expect(setMessagesCall[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }],
|
||||
});
|
||||
expect(setMessagesCall[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
});
|
||||
expect(setMessagesCall[2]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
// Should NOT append the continuation message for manual compaction
|
||||
expect(mockAppend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without append function', async () => {
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
...default_message,
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
},
|
||||
],
|
||||
tokenCounts: [100, 50],
|
||||
});
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
undefined // No append function
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockManageContextFromBackend).toHaveBeenCalled();
|
||||
// Should not throw error when append is undefined
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
// No append function provided, so no calls should be made
|
||||
expect(mockAppend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not auto-continue conversation for manual compaction even with append function', async () => {
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
...default_message,
|
||||
content: [
|
||||
{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...default_message,
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
},
|
||||
{
|
||||
...default_message,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleManualCompaction(
|
||||
mockMessages,
|
||||
mockSetMessages,
|
||||
mockAppend,
|
||||
'test-session-id'
|
||||
);
|
||||
});
|
||||
|
||||
// Verify all three messages are set
|
||||
expect(mockSetMessages).toHaveBeenCalledTimes(1);
|
||||
const setMessagesCall = mockSetMessages.mock.calls[0][0];
|
||||
expect(setMessagesCall).toHaveLength(3);
|
||||
expect(setMessagesCall[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'conversationCompacted', msg: 'Conversation compacted and summarized' }],
|
||||
});
|
||||
expect(setMessagesCall[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
});
|
||||
expect(setMessagesCall[2]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
// Should NOT auto-continue for manual compaction, even with append function
|
||||
expect(mockAppend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context Provider Error', () => {
|
||||
it('should throw error when useContextManager is used outside provider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useContextManager());
|
||||
}).toThrow('useContextManager must be used within a ContextManagerProvider');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ContextManageRequest, ContextManageResponse, manageContext, Message } from '../../api';
|
||||
|
||||
export async function manageContextFromBackend({
|
||||
messages,
|
||||
manageAction,
|
||||
sessionId,
|
||||
}: {
|
||||
messages: Message[];
|
||||
manageAction: 'truncation' | 'summarize';
|
||||
sessionId: string;
|
||||
}): Promise<ContextManageResponse> {
|
||||
const contextManagementRequest = { manageAction, messages, sessionId };
|
||||
|
||||
// Cast to the API-expected type
|
||||
const result = await manageContext({
|
||||
body: contextManagementRequest as unknown as ContextManageRequest,
|
||||
});
|
||||
|
||||
// Check for errors in the result
|
||||
if (result.error) {
|
||||
throw new Error(`Context management failed: ${result.error}`);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error('Context management returned no data');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
Reference in New Issue
Block a user