Replace compaction notifications with system notifications (#5218)

This commit is contained in:
David Katz
2025-10-22 16:09:44 -04:00
committed by GitHub
parent 755e9f893d
commit 23412e270f
31 changed files with 350 additions and 1060 deletions
@@ -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;
}