Skip to content
Merged
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
5 changes: 3 additions & 2 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1757,8 +1757,9 @@ describe('startInteractiveUI', () => {

// Verify all startup tasks were called
expect(getVersion).toHaveBeenCalledTimes(1);
// 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check
expect(registerCleanup).toHaveBeenCalledTimes(5);
// 6 cleanups: mouseEvents, lineWrapping, non-resumable session cleanup,
// instance.unmount, TTY check, and consolePatcher
expect(registerCleanup).toHaveBeenCalledTimes(6);

// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/interactiveCli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ export async function startInteractiveUI(
});

const cleanupUnmount = () => instance.unmount();
const cleanupNonResumableCurrentSession = async () => {
try {
await config
.getGeminiClient()
?.getChatRecordingService()
?.deleteCurrentSessionIfNotResumableAsync();
} catch (e: unknown) {
debugLogger.error('Error cleaning up non-resumable session:', e);
}
};
registerCleanup(cleanupNonResumableCurrentSession);
registerCleanup(cleanupUnmount);

const cleanupTtyCheck = setupTtyCheck();
Expand All @@ -212,6 +223,13 @@ export async function startInteractiveUI(
debugLogger.error('Error cleaning up console patcher:', e);
}

try {
removeCleanup(cleanupNonResumableCurrentSession);
await cleanupNonResumableCurrentSession();
} catch (e: unknown) {
debugLogger.error('Error removing non-resumable session cleanup:', e);
}
Comment thread
SandyTao520 marked this conversation as resolved.

try {
removeCleanup(cleanupUnmount);
instance.unmount();
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3673,9 +3673,12 @@ describe('InputPrompt', () => {
});

it('should toggle paste expansion on double-click', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);

const id = '[Pasted Text: 10 lines]';
const largeText =
'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
const togglePasteExpansion = vi.fn();

const baseProps = props;
const TestWrapper = () => {
Expand Down Expand Up @@ -3714,8 +3717,9 @@ describe('InputPrompt', () => {
row: 0,
col: 2,
}),
togglePasteExpansion: vi.fn().mockImplementation(() => {
setIsExpanded(!isExpanded);
togglePasteExpansion: vi.fn().mockImplementation((...args) => {
togglePasteExpansion(...args);
setIsExpanded((expanded) => !expanded);
}),
getExpandedPasteAtLine: vi
.fn()
Expand Down Expand Up @@ -3746,7 +3750,8 @@ describe('InputPrompt', () => {

// 2. Verify expanded content is visible
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(togglePasteExpansion).toHaveBeenCalledWith(id, 0, 2);
expect(stdout.lastFrame()).toContain('line10');
});

// Simulate double-click to collapse
Expand All @@ -3755,6 +3760,8 @@ describe('InputPrompt', () => {

// 3. Verify placeholder is restored
await waitFor(() => {
expect(togglePasteExpansion).toHaveBeenCalledTimes(2);
expect(stdout.lastFrame()).toContain(id);
expect(stdout.lastFrame()).toMatchSnapshot();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;

exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;

exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
Expand Down
216 changes: 74 additions & 142 deletions packages/cli/src/utils/sessionUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
SessionSelector,
extractFirstUserMessage,
formatRelativeTime,
hasUserOrAssistantMessage,
SessionError,
convertSessionToHistoryFormats,
} from './sessionUtils.js';
Expand Down Expand Up @@ -512,6 +511,80 @@ describe('SessionSelector', () => {
expect(sessions[0].id).toBe(sessionIdWithUser);
});

it('should not list command-only sessions', async () => {
const commandOnlySessionId = randomUUID();

const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });

const metadata = {
sessionId: commandOnlySessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:01:00.000Z',
};
const commandMessage = {
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:30.000Z',
};

await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${commandOnlySessionId.slice(0, 8)}.jsonl`,
),
`${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n`,
);

const sessionSelector = new SessionSelector(storage);
const sessions = await sessionSelector.listSessions();

expect(sessions).toEqual([]);
});

it('should use the first non-command user message for display', async () => {
const sessionId = randomUUID();

const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });

const metadata = {
sessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:02:00.000Z',
};
const commandMessage = {
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:30.000Z',
};
const realMessage = {
type: 'user',
content: 'Help me fix resume history',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
};

await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.jsonl`,
),
`${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n${JSON.stringify(realMessage)}\n`,
);

const sessionSelector = new SessionSelector(storage);
const sessions = await sessionSelector.listSessions();

expect(sessions).toHaveLength(1);
expect(sessions[0].firstUserMessage).toBe('Help me fix resume history');
expect(sessions[0].displayName).toBe('Help me fix resume history');
});

it('should list session with gemini message even without user message', async () => {
const sessionIdGeminiOnly = randomUUID();

Expand Down Expand Up @@ -781,147 +854,6 @@ describe('extractFirstUserMessage', () => {
});
});

describe('hasUserOrAssistantMessage', () => {
it('should return true when session has user message', () => {
const messages = [
{
type: 'user',
content: 'Hello',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(true);
});

it('should return true when session has gemini message', () => {
const messages = [
{
type: 'gemini',
content: 'Hello, how can I help?',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(true);
});

it('should return true when session has both user and gemini messages', () => {
const messages = [
{
type: 'user',
content: 'Hello',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'gemini',
content: 'Hi there!',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(true);
});

it('should return false when session only has info messages', () => {
const messages = [
{
type: 'info',
content: 'Session started',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(false);
});

it('should return false when session only has error messages', () => {
const messages = [
{
type: 'error',
content: 'An error occurred',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(false);
});

it('should return false when session only has warning messages', () => {
const messages = [
{
type: 'warning',
content: 'Warning message',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(false);
});

it('should return false when session only has system messages (mixed)', () => {
const messages = [
{
type: 'info',
content: 'Session started',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'error',
content: 'An error occurred',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
{
type: 'warning',
content: 'Warning message',
id: 'msg3',
timestamp: '2024-01-01T10:02:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(false);
});

it('should return true when session has user message among system messages', () => {
const messages = [
{
type: 'info',
content: 'Session started',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'user',
content: 'Hello',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
{
type: 'error',
content: 'An error occurred',
id: 'msg3',
timestamp: '2024-01-01T10:02:00.000Z',
},
] as MessageRecord[];

expect(hasUserOrAssistantMessage(messages)).toBe(true);
});

it('should return false for empty messages array', () => {
const messages: MessageRecord[] = [];
expect(hasUserOrAssistantMessage(messages)).toBe(false);
});
});

describe('formatRelativeTime', () => {
it('should format time correctly', () => {
const now = new Date();
Expand Down
15 changes: 4 additions & 11 deletions packages/cli/src/utils/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,6 @@ export interface SessionSelectionResult {
displayInfo: string;
}

/**
* Checks if a session has at least one user or assistant (gemini) message.
* Sessions with only system messages (info, error, warning) are considered empty.
* @param messages - The array of message records to check
* @returns true if the session has meaningful content
*/
export const hasUserOrAssistantMessage = (messages: MessageRecord[]): boolean =>
messages.some((msg) => msg.type === 'user' || msg.type === 'gemini');

/**
* Cleans and sanitizes message content for display by:
* - Converting newlines to spaces
Expand Down Expand Up @@ -287,8 +278,10 @@ export const getAllSessionFiles = async (
const lastUpdated =
content.lastUpdated || content.startTime || fallbackTimestamp;

// Skip sessions that only contain system messages (info, error, warning)
if (!content.hasUserOrAssistantMessage) {
// Skip sessions with no resumable conversation content, including
// startup-only, system-only, command-only, and internal-context-only
// sessions.
if (!content.hasResumableContent) {
return { fileName: file, sessionInfo: null };
}

Expand Down
Loading
Loading