fix(cli): preserve input history after /clear command (#5890)

Co-authored-by: Allen Hutchison <adh@google.com>
This commit is contained in:
Hiroaki Mitsuyoshi
2025-08-30 03:18:22 +09:00
committed by GitHub
parent 5e5f2dffc0
commit 6f91cfa9a3
4 changed files with 437 additions and 39 deletions
@@ -0,0 +1,301 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useInputHistoryStore } from './useInputHistoryStore.js';
describe('useInputHistoryStore', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with empty input history', () => {
const { result } = renderHook(() => useInputHistoryStore());
expect(result.current.inputHistory).toEqual([]);
});
it('should add input to history', () => {
const { result } = renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput('test message 1');
});
expect(result.current.inputHistory).toEqual(['test message 1']);
act(() => {
result.current.addInput('test message 2');
});
expect(result.current.inputHistory).toEqual([
'test message 1',
'test message 2',
]);
});
it('should not add empty or whitespace-only inputs', () => {
const { result } = renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput('');
});
expect(result.current.inputHistory).toEqual([]);
act(() => {
result.current.addInput(' ');
});
expect(result.current.inputHistory).toEqual([]);
});
it('should deduplicate consecutive identical messages', () => {
const { result } = renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput('test message');
});
act(() => {
result.current.addInput('test message'); // Same as previous
});
expect(result.current.inputHistory).toEqual(['test message']);
act(() => {
result.current.addInput('different message');
});
act(() => {
result.current.addInput('test message'); // Same as first, but not consecutive
});
expect(result.current.inputHistory).toEqual([
'test message',
'different message',
'test message',
]);
});
it('should initialize from logger successfully', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['newest', 'middle', 'oldest']),
};
const { result } = renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Should reverse the order to oldest first
expect(result.current.inputHistory).toEqual(['oldest', 'middle', 'newest']);
expect(mockLogger.getPreviousUserMessages).toHaveBeenCalledTimes(1);
});
it('should handle logger initialization failure gracefully', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockRejectedValue(new Error('Logger error')),
};
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
expect(result.current.inputHistory).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to initialize input history from logger:',
expect.any(Error),
);
consoleSpy.mockRestore();
});
it('should initialize only once', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['message1', 'message2']),
};
const { result } = renderHook(() => useInputHistoryStore());
// Call initializeFromLogger twice
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Should be called only once
expect(mockLogger.getPreviousUserMessages).toHaveBeenCalledTimes(1);
expect(result.current.inputHistory).toEqual(['message2', 'message1']);
});
it('should handle null logger gracefully', async () => {
const { result } = renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(null);
});
expect(result.current.inputHistory).toEqual([]);
});
it('should trim input before adding to history', () => {
const { result } = renderHook(() => useInputHistoryStore());
act(() => {
result.current.addInput(' test message ');
});
expect(result.current.inputHistory).toEqual(['test message']);
});
describe('deduplication logic from previous implementation', () => {
it('should deduplicate consecutive messages from past sessions during initialization', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue([
'message1',
'message1',
'message2',
'message2',
'message3',
]), // newest first with duplicates
};
const { result } = renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Should deduplicate consecutive messages and reverse to oldest first
expect(result.current.inputHistory).toEqual([
'message3',
'message2',
'message1',
]);
});
it('should deduplicate across session boundaries', async () => {
const mockLogger = {
getPreviousUserMessages: vi.fn().mockResolvedValue(['old2', 'old1']), // newest first
};
const { result } = renderHook(() => useInputHistoryStore());
// Initialize with past session
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Add current session inputs
act(() => {
result.current.addInput('old2'); // Same as last past session message
});
// Should deduplicate across session boundary
expect(result.current.inputHistory).toEqual(['old1', 'old2']);
act(() => {
result.current.addInput('new1');
});
expect(result.current.inputHistory).toEqual(['old1', 'old2', 'new1']);
});
it('should preserve non-consecutive duplicates', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['message2', 'message1', 'message2']), // newest first with non-consecutive duplicate
};
const { result } = renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Non-consecutive duplicates should be preserved
expect(result.current.inputHistory).toEqual([
'message2',
'message1',
'message2',
]);
});
it('should handle complex deduplication with current session', () => {
const { result } = renderHook(() => useInputHistoryStore());
// Add multiple messages with duplicates
act(() => {
result.current.addInput('hello');
});
act(() => {
result.current.addInput('hello'); // consecutive duplicate
});
act(() => {
result.current.addInput('world');
});
act(() => {
result.current.addInput('world'); // consecutive duplicate
});
act(() => {
result.current.addInput('hello'); // non-consecutive duplicate
});
// Should have deduplicated consecutive ones
expect(result.current.inputHistory).toEqual(['hello', 'world', 'hello']);
});
it('should maintain oldest-first order in final output', async () => {
const mockLogger = {
getPreviousUserMessages: vi
.fn()
.mockResolvedValue(['newest', 'middle', 'oldest']), // newest first
};
const { result } = renderHook(() => useInputHistoryStore());
await act(async () => {
await result.current.initializeFromLogger(mockLogger);
});
// Add current session messages
act(() => {
result.current.addInput('current1');
});
act(() => {
result.current.addInput('current2');
});
// Should maintain oldest-first order
expect(result.current.inputHistory).toEqual([
'oldest',
'middle',
'newest',
'current1',
'current2',
]);
});
});
});
@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
interface Logger {
getPreviousUserMessages(): Promise<string[]>;
}
export interface UseInputHistoryStoreReturn {
inputHistory: string[];
addInput: (input: string) => void;
initializeFromLogger: (logger: Logger | null) => Promise<void>;
}
/**
* Hook for independently managing input history.
* Completely separated from chat history and unaffected by /clear commands.
*/
export function useInputHistoryStore(): UseInputHistoryStoreReturn {
const [inputHistory, setInputHistory] = useState<string[]>([]);
const [_pastSessionMessages, setPastSessionMessages] = useState<string[]>([]);
const [_currentSessionMessages, setCurrentSessionMessages] = useState<
string[]
>([]);
const [isInitialized, setIsInitialized] = useState(false);
/**
* Recalculate the complete input history from past and current sessions.
* Applies the same deduplication logic as the previous implementation.
*/
const recalculateHistory = useCallback(
(currentSession: string[], pastSession: string[]) => {
// Combine current session (newest first) + past session (newest first)
const combinedMessages = [...currentSession, ...pastSession];
// Deduplicate consecutive identical messages (same algorithm as before)
const deduplicatedMessages: string[] = [];
if (combinedMessages.length > 0) {
deduplicatedMessages.push(combinedMessages[0]); // Add the newest one unconditionally
for (let i = 1; i < combinedMessages.length; i++) {
if (combinedMessages[i] !== combinedMessages[i - 1]) {
deduplicatedMessages.push(combinedMessages[i]);
}
}
}
// Reverse to oldest first for useInputHistory
setInputHistory(deduplicatedMessages.reverse());
},
[],
);
/**
* Initialize input history from logger with past session data.
* Executed only once at app startup.
*/
const initializeFromLogger = useCallback(
async (logger: Logger | null) => {
if (isInitialized || !logger) return;
try {
const pastMessages = (await logger.getPreviousUserMessages()) || [];
setPastSessionMessages(pastMessages); // Store as newest first
recalculateHistory([], pastMessages);
setIsInitialized(true);
} catch (error) {
// Start with empty history even if logger initialization fails
console.warn('Failed to initialize input history from logger:', error);
setPastSessionMessages([]);
recalculateHistory([], []);
setIsInitialized(true);
}
},
[isInitialized, recalculateHistory],
);
/**
* Add new input to history.
* Recalculates the entire history with deduplication.
*/
const addInput = useCallback(
(input: string) => {
const trimmedInput = input.trim();
if (!trimmedInput) return; // Filter empty/whitespace-only inputs
setCurrentSessionMessages((prevCurrent) => {
const newCurrentSession = [...prevCurrent, trimmedInput];
setPastSessionMessages((prevPast) => {
recalculateHistory(
newCurrentSession.slice().reverse(), // Convert to newest first
prevPast,
);
return prevPast; // No change to past messages
});
return newCurrentSession;
});
},
[recalculateHistory],
);
return {
inputHistory,
addInput,
initializeFromLogger,
};
}