2025-10-06 13:34:00 -06:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
import {
2026-02-02 19:32:13 -05:00
checkExhaustive ,
2025-11-10 18:31:00 -07:00
partListUnionToString ,
2025-11-21 09:16:56 -07:00
SESSION_FILE_PREFIX ,
2026-02-13 17:20:14 -05:00
CoreToolCallStatus ,
2026-02-02 19:32:13 -05:00
type Config ,
type ConversationRecord ,
type MessageRecord ,
2025-10-06 13:34:00 -06:00
} from '@google/gemini-cli-core' ;
import * as fs from 'node:fs/promises' ;
import path from 'node:path' ;
2025-12-05 12:20:15 -05:00
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js' ;
2026-02-13 17:20:14 -05:00
import { MessageType , type HistoryItemWithoutId } from '../ui/types.js' ;
2025-10-06 13:34:00 -06:00
2025-11-19 09:22:17 -07:00
/ * *
* Constant for the resume "latest" identifier .
* Used when -- resume is passed without a value to select the most recent session .
* /
export const RESUME_LATEST = 'latest' ;
2025-12-22 17:44:41 -05:00
/ * *
* Error codes for session - related errors .
* /
export type SessionErrorCode =
| 'NO_SESSIONS_FOUND'
| 'INVALID_SESSION_IDENTIFIER' ;
/ * *
* Error thrown for session - related failures .
* Uses a code field to differentiate between error types .
* /
export class SessionError extends Error {
constructor (
readonly code : SessionErrorCode ,
message : string ,
) {
super ( message ) ;
this . name = 'SessionError' ;
}
/ * *
* Creates an error for when no sessions exist for the current project .
* /
static noSessionsFound ( ) : SessionError {
return new SessionError (
'NO_SESSIONS_FOUND' ,
'No previous sessions found for this project.' ,
) ;
}
/ * *
* Creates an error for when a session identifier is invalid .
* /
static invalidSessionIdentifier ( identifier : string ) : SessionError {
return new SessionError (
'INVALID_SESSION_IDENTIFIER' ,
` Invalid session identifier " ${ identifier } ". \ n Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest. ` ,
) ;
}
}
2025-11-21 09:16:56 -07:00
/ * *
* Represents a text match found during search with surrounding context .
* /
export interface TextMatch {
/** Text content before the match (with ellipsis if truncated) */
before : string ;
/** The exact matched text */
match : string ;
/** Text content after the match (with ellipsis if truncated) */
after : string ;
/** Role of the message author where the match was found */
role : 'user' | 'assistant' ;
}
2025-10-06 13:34:00 -06:00
/ * *
* Session information for display and selection purposes .
* /
export interface SessionInfo {
/** Unique session identifier (filename without .json) */
id : string ;
2025-11-10 18:31:00 -07:00
/** Filename without extension */
file : string ;
2025-10-06 13:34:00 -06:00
/** Full filename including .json extension */
fileName : string ;
2025-11-10 18:31:00 -07:00
/** ISO timestamp when session started */
startTime : string ;
2025-11-21 09:16:56 -07:00
/** Total number of messages in the session */
messageCount : number ;
2025-10-06 13:34:00 -06:00
/** ISO timestamp when session was last updated */
lastUpdated : string ;
2025-11-21 09:16:56 -07:00
/** Display name for the session (typically first user message) */
displayName : string ;
2025-11-10 18:31:00 -07:00
/** Cleaned first user message content */
firstUserMessage : string ;
2025-10-06 13:34:00 -06:00
/** Whether this is the currently active session */
isCurrentSession : boolean ;
2025-11-10 18:31:00 -07:00
/** Display index in the list */
index : number ;
2025-12-05 12:20:15 -05:00
/** AI-generated summary of the session (if available) */
summary? : string ;
2025-11-21 09:16:56 -07:00
/** Full concatenated content (only loaded when needed for search) */
fullContent? : string ;
/** Processed messages with normalized roles (only loaded when needed) */
messages? : Array < { role : 'user' | 'assistant' ; content : string } > ;
/** Search result snippets when filtering */
matchSnippets? : TextMatch [ ] ;
/** Total number of matches found in this session */
matchCount? : number ;
2025-10-06 13:34:00 -06:00
}
/ * *
* Represents a session file , which may be valid or corrupted .
* /
export interface SessionFileEntry {
/** Full filename including .json extension */
fileName : string ;
/** Parsed session info if valid, null if corrupted */
sessionInfo : SessionInfo | null ;
}
2025-11-10 18:31:00 -07:00
/ * *
* Result of resolving a session selection argument .
* /
export interface SessionSelectionResult {
sessionPath : string ;
sessionData : ConversationRecord ;
2025-11-21 09:16:56 -07:00
displayInfo : string ;
2025-11-10 18:31:00 -07:00
}
2025-12-02 13:52:53 -07:00
/ * *
* 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' ) ;
2025-11-21 09:16:56 -07:00
/ * *
* Cleans and sanitizes message content for display by :
* - Converting newlines to spaces
* - Collapsing multiple whitespace to single spaces
* - Removing non - printable characters ( keeping only ASCII 32 - 126 )
* - Trimming leading / trailing whitespace
* @param message - The raw message content to clean
* @returns Sanitized message suitable for display
* /
export const cleanMessage = ( message : string ) : string = >
message
. replace ( /\n+/g , ' ' )
. replace ( /\s+/g , ' ' )
. replace ( /[^\x20-\x7E]+/g , '' ) // Non-printable.
. trim ( ) ;
2025-11-10 18:31:00 -07:00
/ * *
* Extracts the first meaningful user message from conversation messages .
* /
export const extractFirstUserMessage = ( messages : MessageRecord [ ] ) : string = > {
2025-11-21 09:16:56 -07:00
const userMessage = messages
// First try filtering out slash commands.
. filter ( ( msg ) = > {
const content = partListUnionToString ( msg . content ) ;
return (
! content . startsWith ( '/' ) &&
! content . startsWith ( '?' ) &&
content . trim ( ) . length > 0
) ;
} )
. find ( ( msg ) = > msg . type === 'user' ) ;
let content : string ;
2025-11-10 18:31:00 -07:00
if ( ! userMessage ) {
2025-11-21 09:16:56 -07:00
// Fallback to first user message even if it's a slash command
const firstMsg = messages . find ( ( msg ) = > msg . type === 'user' ) ;
if ( ! firstMsg ) return 'Empty conversation' ;
content = cleanMessage ( partListUnionToString ( firstMsg . content ) ) ;
} else {
content = cleanMessage ( partListUnionToString ( userMessage . content ) ) ;
2025-11-10 18:31:00 -07:00
}
2025-11-21 09:16:56 -07:00
return content ;
2025-11-10 18:31:00 -07:00
} ;
/ * *
2025-11-21 09:16:56 -07:00
* Formats a timestamp as relative time .
* @param timestamp - The timestamp to format
* @param style - 'long' ( e . g . "2 hours ago" ) or 'short' ( e . g . "2h" )
2025-11-10 18:31:00 -07:00
* /
2025-11-21 09:16:56 -07:00
export const formatRelativeTime = (
timestamp : string ,
style : 'long' | 'short' = 'long' ,
) : string = > {
2025-11-10 18:31:00 -07:00
const now = new Date ( ) ;
const time = new Date ( timestamp ) ;
const diffMs = now . getTime ( ) - time . getTime ( ) ;
const diffSeconds = Math . floor ( diffMs / 1000 ) ;
const diffMinutes = Math . floor ( diffSeconds / 60 ) ;
const diffHours = Math . floor ( diffMinutes / 60 ) ;
const diffDays = Math . floor ( diffHours / 24 ) ;
2025-11-21 09:16:56 -07:00
if ( style === 'short' ) {
if ( diffSeconds < 1 ) return 'now' ;
if ( diffSeconds < 60 ) return ` ${ diffSeconds } s ` ;
if ( diffMinutes < 60 ) return ` ${ diffMinutes } m ` ;
if ( diffHours < 24 ) return ` ${ diffHours } h ` ;
if ( diffDays < 30 ) return ` ${ diffDays } d ` ;
const diffMonths = Math . floor ( diffDays / 30 ) ;
return diffMonths < 12
? ` ${ diffMonths } mo `
: ` ${ Math . floor ( diffMonths / 12 ) } y ` ;
2025-11-10 18:31:00 -07:00
} else {
2025-11-21 09:16:56 -07:00
if ( diffDays > 0 ) {
return ` ${ diffDays } day ${ diffDays === 1 ? '' : 's' } ago ` ;
} else if ( diffHours > 0 ) {
return ` ${ diffHours } hour ${ diffHours === 1 ? '' : 's' } ago ` ;
} else if ( diffMinutes > 0 ) {
return ` ${ diffMinutes } minute ${ diffMinutes === 1 ? '' : 's' } ago ` ;
} else {
return 'Just now' ;
}
2025-11-10 18:31:00 -07:00
}
} ;
2025-11-21 09:16:56 -07:00
export interface GetSessionOptions {
/** Whether to load full message content (needed for search) */
includeFullContent? : boolean ;
}
2025-10-06 13:34:00 -06:00
/ * *
* Loads all session files ( including corrupted ones ) from the chats directory .
* @returns Array of session file entries , with sessionInfo null for corrupted files
* /
export const getAllSessionFiles = async (
chatsDir : string ,
currentSessionId? : string ,
2025-11-21 09:16:56 -07:00
options : GetSessionOptions = { } ,
2025-10-06 13:34:00 -06:00
) : Promise < SessionFileEntry [ ] > = > {
try {
const files = await fs . readdir ( chatsDir ) ;
const sessionFiles = files
. filter ( ( f ) = > f . startsWith ( SESSION_FILE_PREFIX ) && f . endsWith ( '.json' ) )
. sort ( ) ; // Sort by filename, which includes timestamp
const sessionPromises = sessionFiles . map (
async ( file ) : Promise < SessionFileEntry > = > {
const filePath = path . join ( chatsDir , file ) ;
try {
2026-02-20 22:28:55 +00:00
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
2025-10-06 13:34:00 -06:00
const content : ConversationRecord = JSON . parse (
await fs . readFile ( filePath , 'utf8' ) ,
) ;
// Validate required fields
if (
! content . sessionId ||
! content . messages ||
! Array . isArray ( content . messages ) ||
! content . startTime ||
! content . lastUpdated
) {
// Missing required fields - treat as corrupted
return { fileName : file , sessionInfo : null } ;
}
2025-12-02 13:52:53 -07:00
// Skip sessions that only contain system messages (info, error, warning)
if ( ! hasUserOrAssistantMessage ( content . messages ) ) {
return { fileName : file , sessionInfo : null } ;
2026-02-21 12:41:27 -05:00
}
// Skip subagent sessions - these are implementation details of a tool call
// and shouldn't be surfaced for resumption in the main agent history.
if ( content . kind === 'subagent' ) {
return { fileName : file , sessionInfo : null } ;
2025-12-02 13:52:53 -07:00
}
2025-11-10 18:31:00 -07:00
const firstUserMessage = extractFirstUserMessage ( content . messages ) ;
2025-10-06 13:34:00 -06:00
const isCurrentSession = currentSessionId
? file . includes ( currentSessionId . slice ( 0 , 8 ) )
: false ;
2025-11-21 09:16:56 -07:00
let fullContent : string | undefined ;
let messages :
| Array < { role : 'user' | 'assistant' ; content : string } >
| undefined ;
if ( options . includeFullContent ) {
fullContent = content . messages
. map ( ( msg ) = > partListUnionToString ( msg . content ) )
. join ( ' ' ) ;
messages = content . messages . map ( ( msg ) = > ( {
role :
msg . type === 'user'
? ( 'user' as const )
: ( 'assistant' as const ) ,
content : partListUnionToString ( msg . content ) ,
} ) ) ;
}
2025-10-06 13:34:00 -06:00
const sessionInfo : SessionInfo = {
id : content.sessionId ,
2025-11-10 18:31:00 -07:00
file : file.replace ( '.json' , '' ) ,
2025-10-06 13:34:00 -06:00
fileName : file ,
2025-11-10 18:31:00 -07:00
startTime : content.startTime ,
2025-10-06 13:34:00 -06:00
lastUpdated : content.lastUpdated ,
2025-11-21 09:16:56 -07:00
messageCount : content.messages.length ,
2025-12-05 12:20:15 -05:00
displayName : content.summary
? stripUnsafeCharacters ( content . summary )
: firstUserMessage ,
2025-11-10 18:31:00 -07:00
firstUserMessage ,
2025-10-06 13:34:00 -06:00
isCurrentSession ,
2025-11-10 18:31:00 -07:00
index : 0 , // Will be set after sorting valid sessions
2025-12-05 12:20:15 -05:00
summary : content.summary ,
2025-11-21 09:16:56 -07:00
fullContent ,
messages ,
2025-10-06 13:34:00 -06:00
} ;
return { fileName : file , sessionInfo } ;
} catch {
// File is corrupted (can't read or parse JSON)
return { fileName : file , sessionInfo : null } ;
}
} ,
) ;
2025-11-10 18:31:00 -07:00
2025-10-06 13:34:00 -06:00
return await Promise . all ( sessionPromises ) ;
} catch ( error ) {
// It's expected that the directory might not exist, which is not an error.
if ( error instanceof Error && 'code' in error && error . code === 'ENOENT' ) {
return [ ] ;
}
// For other errors (e.g., permissions), re-throw to be handled by the caller.
throw error ;
}
} ;
/ * *
* Loads all valid session files from the chats directory and converts them to SessionInfo .
* Corrupted files are automatically filtered out .
* /
export const getSessionFiles = async (
chatsDir : string ,
currentSessionId? : string ,
2025-11-21 09:16:56 -07:00
options : GetSessionOptions = { } ,
2025-10-06 13:34:00 -06:00
) : Promise < SessionInfo [ ] > = > {
2025-11-21 09:16:56 -07:00
const allFiles = await getAllSessionFiles (
chatsDir ,
currentSessionId ,
options ,
) ;
2025-10-06 13:34:00 -06:00
// Filter out corrupted files and extract SessionInfo
const validSessions = allFiles
. filter (
( entry ) : entry is { fileName : string ; sessionInfo : SessionInfo } = >
entry . sessionInfo !== null ,
)
. map ( ( entry ) = > entry . sessionInfo ) ;
2025-11-21 09:16:56 -07:00
// Deduplicate sessions by ID
const uniqueSessionsMap = new Map < string , SessionInfo > ( ) ;
for ( const session of validSessions ) {
// If duplicate exists, keep the one with the later lastUpdated timestamp
if (
! uniqueSessionsMap . has ( session . id ) ||
new Date ( session . lastUpdated ) . getTime ( ) >
new Date ( uniqueSessionsMap . get ( session . id ) ! . lastUpdated ) . getTime ( )
) {
uniqueSessionsMap . set ( session . id , session ) ;
}
}
const uniqueSessions = Array . from ( uniqueSessionsMap . values ( ) ) ;
2025-11-10 18:31:00 -07:00
// Sort by startTime (oldest first) for stable session numbering
2025-11-21 09:16:56 -07:00
uniqueSessions . sort (
2025-11-10 18:31:00 -07:00
( a , b ) = > new Date ( a . startTime ) . getTime ( ) - new Date ( b . startTime ) . getTime ( ) ,
) ;
// Set the correct 1-based indexes after sorting
2025-11-21 09:16:56 -07:00
uniqueSessions . forEach ( ( session , index ) = > {
2025-11-10 18:31:00 -07:00
session . index = index + 1 ;
} ) ;
2025-11-21 09:16:56 -07:00
return uniqueSessions ;
2025-10-06 13:34:00 -06:00
} ;
2025-11-10 18:31:00 -07:00
/ * *
* Utility class for session discovery and selection .
* /
export class SessionSelector {
constructor ( private config : Config ) { }
/ * *
* Lists all available sessions for the current project .
* /
async listSessions ( ) : Promise < SessionInfo [ ] > {
const chatsDir = path . join (
this . config . storage . getProjectTempDir ( ) ,
'chats' ,
) ;
return getSessionFiles ( chatsDir , this . config . getSessionId ( ) ) ;
}
/ * *
* Finds a session by identifier ( UUID or numeric index ) .
*
* @param identifier - Can be a full UUID or an index number ( 1 - based )
* @returns Promise resolving to the found SessionInfo
* @throws Error if the session is not found or identifier is invalid
* /
async findSession ( identifier : string ) : Promise < SessionInfo > {
const sessions = await this . listSessions ( ) ;
if ( sessions . length === 0 ) {
2025-12-22 17:44:41 -05:00
throw SessionError . noSessionsFound ( ) ;
2025-11-10 18:31:00 -07:00
}
// Sort by startTime (oldest first, so newest sessions get highest numbers)
const sortedSessions = sessions . sort (
( a , b ) = >
new Date ( a . startTime ) . getTime ( ) - new Date ( b . startTime ) . getTime ( ) ,
) ;
// Try to find by UUID first
const sessionByUuid = sortedSessions . find (
( session ) = > session . id === identifier ,
) ;
if ( sessionByUuid ) {
return sessionByUuid ;
}
// Parse as index number (1-based) - only allow numeric indexes
const index = parseInt ( identifier , 10 ) ;
if (
! isNaN ( index ) &&
index . toString ( ) === identifier &&
index > 0 &&
index <= sortedSessions . length
) {
return sortedSessions [ index - 1 ] ;
}
2025-12-22 17:44:41 -05:00
throw SessionError . invalidSessionIdentifier ( identifier ) ;
2025-11-10 18:31:00 -07:00
}
/ * *
* Resolves a resume argument to a specific session .
*
* @param resumeArg - Can be "latest" , a full UUID , or an index number ( 1 - based )
* @returns Promise resolving to session selection result
* /
async resolveSession ( resumeArg : string ) : Promise < SessionSelectionResult > {
let selectedSession : SessionInfo ;
2025-11-19 09:22:17 -07:00
if ( resumeArg === RESUME_LATEST ) {
2025-11-10 18:31:00 -07:00
const sessions = await this . listSessions ( ) ;
if ( sessions . length === 0 ) {
2026-03-06 11:02:33 -08:00
throw SessionError . noSessionsFound ( ) ;
2025-11-10 18:31:00 -07:00
}
// Sort by startTime (oldest first, so newest sessions get highest numbers)
sessions . sort (
( a , b ) = >
new Date ( a . startTime ) . getTime ( ) - new Date ( b . startTime ) . getTime ( ) ,
) ;
selectedSession = sessions [ sessions . length - 1 ] ;
} else {
try {
selectedSession = await this . findSession ( resumeArg ) ;
} catch ( error ) {
2025-12-22 17:44:41 -05:00
// SessionError already has detailed messages - just rethrow
if ( error instanceof SessionError ) {
throw error ;
}
// Wrap unexpected errors with context
2025-11-10 18:31:00 -07:00
throw new Error (
2025-12-22 17:44:41 -05:00
` Failed to find session " ${ resumeArg } ": ${ error instanceof Error ? error.message : String ( error ) } ` ,
2025-11-10 18:31:00 -07:00
) ;
}
}
return this . selectSession ( selectedSession ) ;
}
/ * *
* Loads session data for a selected session .
* /
private async selectSession (
sessionInfo : SessionInfo ,
) : Promise < SessionSelectionResult > {
const chatsDir = path . join (
this . config . storage . getProjectTempDir ( ) ,
'chats' ,
) ;
const sessionPath = path . join ( chatsDir , sessionInfo . fileName ) ;
try {
2026-02-20 22:28:55 +00:00
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
2025-11-10 18:31:00 -07:00
const sessionData : ConversationRecord = JSON . parse (
await fs . readFile ( sessionPath , 'utf8' ) ,
) ;
2025-11-21 09:16:56 -07:00
const displayInfo = ` Session ${ sessionInfo . index } : ${ sessionInfo . firstUserMessage } ( ${ sessionInfo . messageCount } messages, ${ formatRelativeTime ( sessionInfo . lastUpdated ) } ) ` ;
2025-11-10 18:31:00 -07:00
return {
sessionPath ,
sessionData ,
2025-11-21 09:16:56 -07:00
displayInfo ,
2025-11-10 18:31:00 -07:00
} ;
} catch ( error ) {
throw new Error (
` Failed to load session ${ sessionInfo . id } : ${ error instanceof Error ? error . message : 'Unknown error' } ` ,
) ;
}
}
}
2026-02-01 14:16:57 -05:00
/ * *
2026-02-22 20:18:07 -05:00
* Converts session / conversation data into UI history format .
2026-02-01 14:16:57 -05:00
* /
export function convertSessionToHistoryFormats (
messages : ConversationRecord [ 'messages' ] ,
) : {
uiHistory : HistoryItemWithoutId [ ] ;
} {
const uiHistory : HistoryItemWithoutId [ ] = [ ] ;
for ( const msg of messages ) {
2026-03-06 20:20:27 -08:00
// Add thoughts if present
if ( msg . type === 'gemini' && msg . thoughts && msg . thoughts . length > 0 ) {
for ( const thought of msg . thoughts ) {
uiHistory . push ( {
type : 'thinking' ,
thought : {
subject : thought.subject ,
description : thought.description ,
} ,
} ) ;
}
}
2026-02-01 14:16:57 -05:00
// Add the message only if it has content
const displayContentString = msg . displayContent
? partListUnionToString ( msg . displayContent )
: undefined ;
const contentString = partListUnionToString ( msg . content ) ;
const uiText = displayContentString || contentString ;
if ( uiText . trim ( ) ) {
let messageType : MessageType ;
switch ( msg . type ) {
case 'user' :
messageType = MessageType . USER ;
break ;
case 'info' :
messageType = MessageType . INFO ;
break ;
case 'error' :
messageType = MessageType . ERROR ;
break ;
case 'warning' :
messageType = MessageType . WARNING ;
break ;
case 'gemini' :
messageType = MessageType . GEMINI ;
break ;
default :
checkExhaustive ( msg ) ;
messageType = MessageType . GEMINI ;
break ;
}
uiHistory . push ( {
type : messageType ,
text : uiText ,
} ) ;
}
// Add tool calls if present
if (
msg . type !== 'user' &&
'toolCalls' in msg &&
msg . toolCalls &&
msg . toolCalls . length > 0
) {
uiHistory . push ( {
type : 'tool_group' ,
tools : msg.toolCalls.map ( ( tool ) = > ( {
callId : tool.id ,
name : tool.displayName || tool . name ,
description : tool.description || '' ,
renderOutputAsMarkdown : tool.renderOutputAsMarkdown ? ? true ,
status :
tool . status === 'success'
2026-02-13 17:20:14 -05:00
? CoreToolCallStatus . Success
: CoreToolCallStatus . Error ,
2026-02-01 14:16:57 -05:00
resultDisplay : tool.resultDisplay ,
confirmationDetails : undefined ,
} ) ) ,
} ) ;
}
}
return {
uiHistory ,
} ;
}