diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 5e740de80a..4c7fec75a7 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -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];
diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx
index fd8d71f57f..266788745e 100644
--- a/packages/cli/src/interactiveCli.tsx
+++ b/packages/cli/src/interactiveCli.tsx
@@ -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();
@@ -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);
+ }
+
try {
removeCleanup(cleanupUnmount);
instance.unmount();
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 9413ae79a4..a2b0788f93 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -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 = () => {
@@ -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()
@@ -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
@@ -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();
});
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index db449ce4d7..4bed0578fa 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -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
diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts
index cbd033a2c6..5677da5727 100644
--- a/packages/cli/src/utils/sessionUtils.test.ts
+++ b/packages/cli/src/utils/sessionUtils.test.ts
@@ -9,7 +9,6 @@ import {
SessionSelector,
extractFirstUserMessage,
formatRelativeTime,
- hasUserOrAssistantMessage,
SessionError,
convertSessionToHistoryFormats,
} from './sessionUtils.js';
@@ -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();
@@ -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();
diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts
index 2abab79e90..2830451aa0 100644
--- a/packages/cli/src/utils/sessionUtils.ts
+++ b/packages/cli/src/utils/sessionUtils.ts
@@ -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
@@ -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 };
}
diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts
index cc3e174cf0..133e9ffe4d 100644
--- a/packages/core/src/services/chatRecordingService.test.ts
+++ b/packages/core/src/services/chatRecordingService.test.ts
@@ -40,6 +40,8 @@ vi.mock('node:fs', async (importOriginal) => {
import {
ChatRecordingService,
+ hasResumableConversationContent,
+ isResumableMessageRecord,
loadConversationRecord,
type ConversationRecord,
type ToolCallRecord,
@@ -125,6 +127,76 @@ describe('ChatRecordingService', () => {
}
});
+ describe('isResumableMessageRecord', () => {
+ it('should treat malformed messages without content as non-resumable', () => {
+ const message = {
+ id: 'malformed-message',
+ timestamp: '2024-01-01T00:00:00.000Z',
+ type: 'user',
+ } as MessageRecord;
+
+ expect(() => isResumableMessageRecord(message)).not.toThrow();
+ expect(isResumableMessageRecord(message)).toBe(false);
+ });
+
+ it('should return false for command-only messages', () => {
+ const messages = [
+ {
+ type: 'user',
+ content: '/resume',
+ id: 'msg1',
+ timestamp: '2024-01-01T10:00:00.000Z',
+ },
+ {
+ type: 'user',
+ content: '?help',
+ id: 'msg2',
+ timestamp: '2024-01-01T10:01:00.000Z',
+ },
+ ] as MessageRecord[];
+
+ expect(hasResumableConversationContent(messages)).toBe(false);
+ });
+
+ it('should return false for internal context-only messages', () => {
+ const messages = [
+ {
+ type: 'user',
+ content: 'previous state',
+ id: 'msg1',
+ timestamp: '2024-01-01T10:00:00.000Z',
+ },
+ {
+ type: 'user',
+ content: 'hook data',
+ id: 'msg2',
+ timestamp: '2024-01-01T10:01:00.000Z',
+ },
+ ] as MessageRecord[];
+
+ expect(hasResumableConversationContent(messages)).toBe(false);
+ });
+
+ it('should return true for real user or assistant content', () => {
+ const messages = [
+ {
+ type: 'user',
+ content: '/resume',
+ id: 'msg1',
+ timestamp: '2024-01-01T10:00:00.000Z',
+ },
+ {
+ type: 'gemini',
+ content: 'I can help with that.',
+ id: 'msg2',
+ timestamp: '2024-01-01T10:01:00.000Z',
+ },
+ ] as MessageRecord[];
+
+ expect(hasResumableConversationContent(messages)).toBe(true);
+ });
+ });
+
describe('initialize', () => {
it('should create a new session if none is provided', async () => {
await chatRecordingService.initialize();
@@ -838,6 +910,49 @@ describe('ChatRecordingService', () => {
});
});
+ describe('deleteCurrentSessionIfNotResumableAsync', () => {
+ it('should delete a startup-only session', async () => {
+ await chatRecordingService.initialize();
+ const conversationFile = chatRecordingService.getConversationFilePath();
+ expect(conversationFile).not.toBeNull();
+ expect(fs.existsSync(conversationFile!)).toBe(true);
+
+ await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();
+
+ expect(fs.existsSync(conversationFile!)).toBe(false);
+ });
+
+ it('should delete a command-only session', async () => {
+ await chatRecordingService.initialize();
+ chatRecordingService.recordMessage({
+ type: 'user',
+ content: '/resume',
+ model: 'gemini-pro',
+ });
+ const conversationFile = chatRecordingService.getConversationFilePath();
+ expect(conversationFile).not.toBeNull();
+
+ await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();
+
+ expect(fs.existsSync(conversationFile!)).toBe(false);
+ });
+
+ it('should keep a session with a real user message', async () => {
+ await chatRecordingService.initialize();
+ chatRecordingService.recordMessage({
+ type: 'user',
+ content: 'Help me debug this test',
+ model: 'gemini-pro',
+ });
+ const conversationFile = chatRecordingService.getConversationFilePath();
+ expect(conversationFile).not.toBeNull();
+
+ await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();
+
+ expect(fs.existsSync(conversationFile!)).toBe(true);
+ });
+ });
+
describe('recordDirectories', () => {
beforeEach(async () => {
await chatRecordingService.initialize();
diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts
index 533a7a7459..18b977bf00 100644
--- a/packages/core/src/services/chatRecordingService.ts
+++ b/packages/core/src/services/chatRecordingService.ts
@@ -23,6 +23,8 @@ import type {
import { debugLogger } from '../utils/debugLogger.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
+import { partListUnionToString } from '../core/geminiRequest.js';
+import { isIgnoredUserContent } from '../utils/sessionUtils.js';
import {
SESSION_FILE_PREFIX,
type TokensSummary,
@@ -98,6 +100,36 @@ function isTextPart(part: unknown): part is { text: string } {
return isStringProperty(part, 'text');
}
+/**
+ * Returns true when a stored message represents conversation content worth
+ * surfacing in resume flows.
+ */
+export function isResumableMessageRecord(message: MessageRecord): boolean {
+ const contentString = message.content
+ ? partListUnionToString(message.content)
+ : '';
+
+ if (message.type === 'user') {
+ return !isIgnoredUserContent(contentString.trim());
+ }
+
+ if (message.type === 'gemini') {
+ return (
+ contentString.trim().length > 0 ||
+ (message.toolCalls?.length ?? 0) > 0 ||
+ (message.thoughts?.length ?? 0) > 0
+ );
+ }
+
+ return false;
+}
+
+export function hasResumableConversationContent(
+ messages: readonly MessageRecord[],
+): boolean {
+ return messages.some((message) => isResumableMessageRecord(message));
+}
+
export async function loadConversationRecord(
filePath: string,
options?: LoadConversationOptions,
@@ -106,7 +138,7 @@ export async function loadConversationRecord(
messageCount?: number;
userMessageCount?: number;
firstUserMessage?: string;
- hasUserOrAssistantMessage?: boolean;
+ hasResumableContent?: boolean;
memoryScratchpadIsStale?: boolean;
})
| null
@@ -127,7 +159,7 @@ export async function loadConversationRecord(
const messageIds: string[] = [];
const messageKinds = new Map<
string,
- { isUser: boolean; isUserOrAssistant: boolean }
+ { isUser: boolean; isResumable: boolean }
>();
let isTrackingMemoryScratchpadFreshness = false;
let memoryScratchpadIsStale = false;
@@ -174,19 +206,18 @@ export async function loadConversationRecord(
}
const id = record.id;
const isUser = hasProperty(record, 'type') && record.type === 'user';
- const isUserOrAssistant =
- hasProperty(record, 'type') &&
- (record.type === 'user' || record.type === 'gemini');
+ const isResumable = isResumableMessageRecord(record);
// Track message count and first user message
if (options?.metadataOnly) {
messageIds.push(id);
- messageKinds.set(id, { isUser, isUserOrAssistant });
+ messageKinds.set(id, { isUser, isResumable });
}
if (
!firstUserMessageStr &&
isUser &&
hasProperty(record, 'content') &&
- record['content']
+ record['content'] &&
+ isResumable
) {
// Basic extraction of first user message for display
const rawContent = record['content'];
@@ -230,12 +261,14 @@ export async function loadConversationRecord(
if (isMessageRecord(msg)) {
const id = msg.id;
const isUser = msg.type === 'user';
- const isUserOrAssistant =
- msg.type === 'user' || msg.type === 'gemini';
+ const isResumable = isResumableMessageRecord(msg);
if (options?.metadataOnly) {
messageIds.push(id);
- messageKinds.set(id, { isUser, isUserOrAssistant });
+ messageKinds.set(id, {
+ isUser,
+ isResumable,
+ });
} else {
messagesMap.set(id, msg);
}
@@ -243,6 +276,7 @@ export async function loadConversationRecord(
if (
!firstUserMessageStr &&
isUser &&
+ isResumable &&
msg.content &&
(Array.isArray(msg.content) ||
typeof msg.content === 'string')
@@ -274,12 +308,14 @@ export async function loadConversationRecord(
if (isMessageRecord(msg)) {
const id = msg.id;
const isUser = msg.type === 'user';
- const isUserOrAssistant =
- msg.type === 'user' || msg.type === 'gemini';
+ const isResumable = isResumableMessageRecord(msg);
if (options?.metadataOnly) {
messageIds.push(id);
- messageKinds.set(id, { isUser, isUserOrAssistant });
+ messageKinds.set(id, {
+ isUser,
+ isResumable,
+ });
} else {
messagesMap.set(id, msg);
}
@@ -287,6 +323,7 @@ export async function loadConversationRecord(
if (
!firstUserMessageStr &&
isUser &&
+ isResumable &&
msg.content &&
(Array.isArray(msg.content) ||
typeof msg.content === 'string')
@@ -314,7 +351,10 @@ export async function loadConversationRecord(
const loadedMessages = Array.from(messagesMap.values());
const metadataFirstUserMessage =
- loadedMessages.find((message) => message.type === 'user') ?? null;
+ loadedMessages.find(
+ (message) =>
+ message.type === 'user' && isResumableMessageRecord(message),
+ ) ?? null;
let fallbackFirstUserMessage = firstUserMessageStr;
if (!fallbackFirstUserMessage && metadataFirstUserMessage) {
const rawContent = metadataFirstUserMessage.content;
@@ -329,9 +369,9 @@ export async function loadConversationRecord(
const userMessageCount = options?.metadataOnly
? Array.from(messageKinds.values()).filter((m) => m.isUser).length
: loadedMessages.filter((m) => m.type === 'user').length;
- const hasUserOrAssistant = options?.metadataOnly
- ? Array.from(messageKinds.values()).some((m) => m.isUserOrAssistant)
- : loadedMessages.some((m) => m.type === 'user' || m.type === 'gemini');
+ const hasResumableContent = options?.metadataOnly
+ ? Array.from(messageKinds.values()).some((m) => m.isResumable)
+ : hasResumableConversationContent(loadedMessages);
return {
sessionId: metadata.sessionId,
@@ -351,7 +391,7 @@ export async function loadConversationRecord(
? memoryScratchpadIsStale
: undefined,
firstUserMessage: fallbackFirstUserMessage,
- hasUserOrAssistantMessage: hasUserOrAssistant,
+ hasResumableContent,
};
} catch (error) {
debugLogger.error('Error loading conversation record from JSONL:', error);
@@ -791,6 +831,23 @@ export class ChatRecordingService {
}
}
+ /**
+ * Deletes the current session only if it has no resumable conversation
+ * content. This removes abandoned startup-only sessions while preserving any
+ * session with a real user prompt, model response, or tool activity.
+ */
+ async deleteCurrentSessionIfNotResumableAsync(): Promise {
+ if (!this.conversationFile || !this.cachedConversation) {
+ return;
+ }
+
+ if (hasResumableConversationContent(this.cachedConversation.messages)) {
+ return;
+ }
+
+ await this.deleteCurrentSessionAsync();
+ }
+
/**
* Rewinds the conversation to the state just before the specified message ID.
* All messages from (and including) the specified ID onwards are removed.
@@ -913,7 +970,7 @@ async function parseLegacyRecordFallback(
messageCount?: number;
userMessageCount?: number;
firstUserMessage?: string;
- hasUserOrAssistantMessage?: boolean;
+ hasResumableContent?: boolean;
})
| null
> {
@@ -929,7 +986,7 @@ async function parseLegacyRecordFallback(
if (options?.metadataOnly) {
let fallbackFirstUserMessageStr: string | undefined;
const firstUserMessage = legacyRecord.messages?.find(
- (m) => m.type === 'user',
+ (m) => m.type === 'user' && isResumableMessageRecord(m),
);
if (firstUserMessage) {
const rawContent = firstUserMessage.content;
@@ -948,20 +1005,18 @@ async function parseLegacyRecordFallback(
userMessageCount:
legacyRecord.messages?.filter((m) => m.type === 'user').length || 0,
firstUserMessage: fallbackFirstUserMessageStr,
- hasUserOrAssistantMessage:
- legacyRecord.messages?.some(
- (m) => m.type === 'user' || m.type === 'gemini',
- ) || false,
+ hasResumableContent:
+ legacyRecord.messages?.some((m) => isResumableMessageRecord(m)) ||
+ false,
};
}
return {
...legacyRecord,
userMessageCount:
legacyRecord.messages?.filter((m) => m.type === 'user').length || 0,
- hasUserOrAssistantMessage:
- legacyRecord.messages?.some(
- (m) => m.type === 'user' || m.type === 'gemini',
- ) || false,
+ hasResumableContent:
+ legacyRecord.messages?.some((m) => isResumableMessageRecord(m)) ||
+ false,
};
}
} catch {
diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts
index 763a29e990..9dd30c2e89 100644
--- a/packages/core/src/utils/sessionUtils.ts
+++ b/packages/core/src/utils/sessionUtils.ts
@@ -94,6 +94,16 @@ function ensurePartArray(content: PartListUnion): Part[] {
return [content];
}
+export function isIgnoredUserContent(trimmedContent: string): boolean {
+ return (
+ trimmedContent.length === 0 ||
+ trimmedContent.startsWith('/') ||
+ trimmedContent.startsWith('?') ||
+ trimmedContent.startsWith('') ||
+ trimmedContent.startsWith('')
+ );
+}
+
/**
* Converts session/conversation data into Gemini client history formats.
*/
@@ -110,12 +120,7 @@ export function convertSessionToClientHistory(
if (msg.type === 'user') {
const contentString = partListUnionToString(msg.content);
const trimmedContent = contentString.trim();
- if (
- trimmedContent.startsWith('/') ||
- trimmedContent.startsWith('?') ||
- trimmedContent.startsWith('') ||
- trimmedContent.startsWith('')
- ) {
+ if (isIgnoredUserContent(trimmedContent)) {
continue;
}