diff --git a/apps/cli/ai/sessions/context.ts b/apps/cli/ai/sessions/context.ts new file mode 100644 index 0000000000..72f787d2a7 --- /dev/null +++ b/apps/cli/ai/sessions/context.ts @@ -0,0 +1,57 @@ +import { AI_MODELS, type AiModelId } from 'cli/ai/agent'; +import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers'; +import type { LoadedAiSession } from './types'; + +function isAiProviderId( value: string ): value is AiProviderId { + return Object.prototype.hasOwnProperty.call( AI_PROVIDERS, value ); +} + +function isAiModelId( value: string ): value is AiModelId { + return Object.prototype.hasOwnProperty.call( AI_MODELS, value ); +} + +export interface ResumeSessionContext { + sessionId?: string; + provider?: AiProviderId; + model?: AiModelId; +} + +export function resolveResumeSessionContext( + resumeSession?: LoadedAiSession +): ResumeSessionContext { + if ( ! resumeSession ) { + return {}; + } + + const context: ResumeSessionContext = {}; + const linkedSessionId = resumeSession.summary.agentSessionId?.trim(); + if ( linkedSessionId ) { + context.sessionId = linkedSessionId; + } + + for ( let index = resumeSession.events.length - 1; index >= 0; index -= 1 ) { + const event = resumeSession.events[ index ]; + + if ( ! context.sessionId && event.type === 'sdk.message' ) { + const sessionId = ( event.message as { session_id?: unknown } ).session_id; + if ( typeof sessionId === 'string' && sessionId.trim().length > 0 ) { + context.sessionId = sessionId; + } + } + + if ( event.type === 'session.context' ) { + if ( ! context.provider && isAiProviderId( event.provider ) ) { + context.provider = event.provider; + } + if ( ! context.model && isAiModelId( event.model ) ) { + context.model = event.model; + } + } + + if ( context.sessionId && context.provider && context.model ) { + break; + } + } + + return context; +} diff --git a/apps/cli/ai/sessions/file-naming.ts b/apps/cli/ai/sessions/file-naming.ts new file mode 100644 index 0000000000..6e63dbeb24 --- /dev/null +++ b/apps/cli/ai/sessions/file-naming.ts @@ -0,0 +1,37 @@ +import path from 'path'; + +const SESSION_FILE_EXTENSION = '.jsonl'; +const SESSION_ID_REGEX = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i; + +/** + * Session files follow this stable naming contract: + * `-.jsonl` + * + * Example: + * `2026-03-11T10-00-00-8b4cead7-3f8b-4727-86cd-a495c055850a.jsonl` + * + * Why this format: + * - The ISO-8601-derived timestamp prefix keeps files lexicographically sortable by start time. + * - `:` is replaced with `-` so filenames are valid on Windows. + * - UUID suffix guarantees uniqueness for sessions started in the same second. + */ +export function buildAiSessionFileName( startedAt: Date, sessionId: string ): string { + const sortableTimestamp = startedAt + .toISOString() + .replace( /:/g, '-' ) + .replace( /\.\d{3}Z$/, '' ); + return `${ sortableTimestamp }-${ sessionId }${ SESSION_FILE_EXTENSION }`; +} + +/** + * Reads the canonical session id from a session file path produced by + * `buildAiSessionFileName()`. + * + * If the file does not match the expected contract, we fall back to the bare + * filename (without extension) so older/unexpected files remain listable. + */ +export function extractAiSessionIdFromFilePath( filePath: string ): string { + const fileName = path.basename( filePath, SESSION_FILE_EXTENSION ); + const uuidMatch = fileName.match( SESSION_ID_REGEX ); + return uuidMatch?.[ 1 ] ?? fileName; +} diff --git a/apps/cli/ai/sessions/paths.ts b/apps/cli/ai/sessions/paths.ts new file mode 100644 index 0000000000..5570d19bfa --- /dev/null +++ b/apps/cli/ai/sessions/paths.ts @@ -0,0 +1,19 @@ +import path from 'path'; +import { getAppdataDirectory } from 'cli/lib/appdata'; + +// Keep month/day segments zero-padded so directory names sort chronologically. +function formatDatePart( value: number ): string { + return String( value ).padStart( 2, '0' ); +} + +export function getAiSessionsRootDirectory(): string { + return path.join( getAppdataDirectory(), 'sessions' ); +} + +// Bucket sessions by local calendar date: ///
. +export function getAiSessionsDirectoryForDate( date: Date ): string { + const year = String( date.getFullYear() ); + const month = formatDatePart( date.getMonth() + 1 ); + const day = formatDatePart( date.getDate() ); + return path.join( getAiSessionsRootDirectory(), year, month, day ); +} diff --git a/apps/cli/ai/sessions/recorder.ts b/apps/cli/ai/sessions/recorder.ts new file mode 100644 index 0000000000..b0aa422d2e --- /dev/null +++ b/apps/cli/ai/sessions/recorder.ts @@ -0,0 +1,163 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import { buildAiSessionFileName } from './file-naming'; +import { getAiSessionsDirectoryForDate } from './paths'; +import type { AiSessionEvent, TurnStatus } from './types'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { AiModelId } from 'cli/ai/agent'; +import type { AiProviderId } from 'cli/ai/providers'; + +function toIsoTimestamp( value?: Date ): string { + return ( value ?? new Date() ).toISOString(); +} + +export class AiSessionRecorder { + public readonly sessionId: string; + public readonly filePath: string; + + private linkedAgentSessionIds = new Set< string >(); + + private constructor( sessionId: string, filePath: string, linkedAgentSessionIds: string[] = [] ) { + this.sessionId = sessionId; + this.filePath = filePath; + this.linkedAgentSessionIds = new Set( linkedAgentSessionIds ); + } + + static async create( options: { startedAt?: Date } = {} ): Promise< AiSessionRecorder > { + const startedAt = options.startedAt ?? new Date(); + const sessionId = crypto.randomUUID(); + const directory = getAiSessionsDirectoryForDate( startedAt ); + const fileName = buildAiSessionFileName( startedAt, sessionId ); + const filePath = path.join( directory, fileName ); + + await fs.mkdir( directory, { recursive: true } ); + + const recorder = new AiSessionRecorder( sessionId, filePath ); + await recorder.appendEvent( { + type: 'session.started', + timestamp: toIsoTimestamp( startedAt ), + version: 1, + sessionId, + } ); + + return recorder; + } + + static async open( options: { + sessionId: string; + filePath: string; + linkedAgentSessionIds?: string[]; + } ): Promise< AiSessionRecorder > { + await fs.access( options.filePath ); + return new AiSessionRecorder( + options.sessionId, + options.filePath, + options.linkedAgentSessionIds ?? [] + ); + } + + async recordAgentSessionId( agentSessionId: string ): Promise< void > { + if ( this.linkedAgentSessionIds.has( agentSessionId ) ) { + return; + } + + this.linkedAgentSessionIds.add( agentSessionId ); + await this.appendEvent( { + type: 'session.linked', + timestamp: toIsoTimestamp(), + agentSessionId, + } ); + } + + async recordSessionContext( options: { + provider: AiProviderId; + model: AiModelId; + } ): Promise< void > { + await this.appendEvent( { + type: 'session.context', + timestamp: toIsoTimestamp(), + provider: options.provider, + model: options.model, + } ); + } + + async recordSiteSelected( site: { + name: string; + path: string; + remote?: boolean; + url?: string; + } ): Promise< void > { + await this.appendEvent( { + type: 'site.selected', + timestamp: toIsoTimestamp(), + siteName: site.name, + sitePath: site.path, + remote: site.remote, + url: site.url, + } ); + } + + async recordUserMessage( options: { + text: string; + source: 'prompt' | 'ask_user'; + sitePath?: string; + } ): Promise< void > { + await this.appendEvent( { + type: 'user.message', + timestamp: toIsoTimestamp(), + text: options.text, + source: options.source, + sitePath: options.sitePath, + } ); + } + + async recordSdkMessage( message: SDKMessage, timestamp?: string ): Promise< void > { + await this.appendEvent( { + type: 'sdk.message', + timestamp: timestamp ?? toIsoTimestamp(), + message, + } ); + } + + async recordToolProgress( message: string, timestamp?: string ): Promise< void > { + if ( ! message.trim() ) { + return; + } + + await this.appendEvent( { + type: 'tool.progress', + timestamp: timestamp ?? toIsoTimestamp(), + message, + } ); + } + + async recordAgentQuestion( options: { + question: string; + options: Array< { + label: string; + description: string; + } >; + } ): Promise< void > { + await this.appendEvent( { + type: 'agent.question', + timestamp: toIsoTimestamp(), + question: options.question, + options: options.options, + } ); + } + + async recordTurnClosed( status: TurnStatus ): Promise< void > { + await this.appendEvent( { + type: 'turn.closed', + timestamp: toIsoTimestamp(), + status, + } ); + } + + private async appendEvent( event: AiSessionEvent ): Promise< void > { + await fs.appendFile( this.filePath, `${ JSON.stringify( event ) }\n`, { + encoding: 'utf8', + } ); + } +} diff --git a/apps/cli/ai/sessions/replay.ts b/apps/cli/ai/sessions/replay.ts new file mode 100644 index 0000000000..a52de7a65e --- /dev/null +++ b/apps/cli/ai/sessions/replay.ts @@ -0,0 +1,70 @@ +import { AiChatUI } from 'cli/ai/ui'; +import type { AiSessionEvent } from './types'; + +export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): void { + ui.prepareForReplay(); + let isTurnOpen = false; + + try { + for ( const event of events ) { + ui.setReplayTimestamp( event.timestamp ); + + if ( event.type === 'site.selected' ) { + ui.setActiveSite( + { + name: event.siteName, + path: event.sitePath, + running: false, + remote: event.remote === true, + url: typeof event.url === 'string' ? event.url : undefined, + }, + { announce: true, emitEvent: false } + ); + continue; + } + + if ( event.type === 'user.message' ) { + if ( event.source === 'ask_user' ) { + continue; + } + + // Defensive close if the previous turn never emitted turn.closed. + if ( isTurnOpen ) { + ui.endAgentTurn(); + } + + ui.beginAgentTurn(); + isTurnOpen = true; + ui.addUserMessage( event.text ); + continue; + } + + if ( event.type === 'sdk.message' ) { + ui.handleMessage( event.message ); + continue; + } + + if ( event.type === 'tool.progress' ) { + ui.setLoaderMessage( event.message ); + continue; + } + + if ( event.type === 'agent.question' ) { + ui.showAgentQuestion( event.question, event.options ); + continue; + } + + if ( event.type === 'turn.closed' ) { + if ( isTurnOpen ) { + ui.endAgentTurn(); + isTurnOpen = false; + } + } + } + } finally { + if ( isTurnOpen ) { + ui.endAgentTurn(); + } + ui.finishReplay(); + } +} diff --git a/apps/cli/ai/sessions/store.ts b/apps/cli/ai/sessions/store.ts new file mode 100644 index 0000000000..b593ca6ad9 --- /dev/null +++ b/apps/cli/ai/sessions/store.ts @@ -0,0 +1,140 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { getAiSessionsRootDirectory } from './paths'; +import { readAiSessionSummaryFromEvents } from './summary'; +import type { AiSessionEvent, AiSessionSummary, LoadedAiSession } from './types'; + +export async function readAiSessionEventsFromFile( filePath: string ): Promise< AiSessionEvent[] > { + const content = await fs.readFile( filePath, 'utf8' ); + const lines = content + .split( '\n' ) + .map( ( line ) => line.trim() ) + .filter( ( line ) => line.length > 0 ); + const events: AiSessionEvent[] = []; + + for ( const line of lines ) { + try { + events.push( JSON.parse( line ) as AiSessionEvent ); + } catch { + // Ignore malformed lines and keep loading the rest. + } + } + + return events; +} + +async function listSessionFilesRecursively( directory: string ): Promise< string[] > { + try { + const entries = await fs.readdir( directory, { withFileTypes: true, encoding: 'utf8' } ); + + const nestedFiles = await Promise.all( + entries.map( async ( entry ) => { + const fullPath = path.join( directory, entry.name ); + + if ( entry.isDirectory() ) { + return listSessionFilesRecursively( fullPath ); + } + + if ( entry.isFile() && entry.name.endsWith( '.jsonl' ) ) { + return [ fullPath ]; + } + + return []; + } ) + ); + + return nestedFiles.flat(); + } catch ( error ) { + const fsError = error as NodeJS.ErrnoException; + if ( fsError.code === 'ENOENT' ) { + return []; + } + + throw error; + } +} + +async function resolveSessionByIdOrPrefix( + sessionIdOrPrefix: string +): Promise< AiSessionSummary > { + const sessions = await listAiSessions(); + const exactMatch = sessions.find( ( session ) => session.id === sessionIdOrPrefix ); + const candidates = exactMatch + ? [ exactMatch ] + : sessions.filter( ( session ) => session.id.startsWith( sessionIdOrPrefix ) ); + + if ( candidates.length === 0 ) { + throw new Error( `AI session not found: ${ sessionIdOrPrefix }` ); + } + + if ( candidates.length > 1 ) { + const sample = candidates + .slice( 0, 5 ) + .map( ( session ) => session.id ) + .join( ', ' ); + throw new Error( + `Session id prefix is ambiguous: ${ sessionIdOrPrefix }. Matches: ${ sample }${ + candidates.length > 5 ? ', …' : '' + }` + ); + } + + return candidates[ 0 ]; +} + +async function pruneEmptySessionDirectories( startDirectory: string ): Promise< void > { + const rootDirectory = getAiSessionsRootDirectory(); + let currentDirectory = startDirectory; + + while ( + currentDirectory.startsWith( rootDirectory + path.sep ) && + currentDirectory !== rootDirectory + ) { + try { + await fs.rmdir( currentDirectory ); + } catch ( error ) { + const fsError = error as NodeJS.ErrnoException; + if ( fsError.code === 'ENOTEMPTY' || fsError.code === 'ENOENT' ) { + return; + } + + throw error; + } + + currentDirectory = path.dirname( currentDirectory ); + } +} + +export async function listAiSessions(): Promise< AiSessionSummary[] > { + const sessionFiles = await listSessionFilesRecursively( getAiSessionsRootDirectory() ); + const results = await Promise.allSettled( + sessionFiles.map( async ( filePath ) => { + const events = await readAiSessionEventsFromFile( filePath ); + return readAiSessionSummaryFromEvents( filePath, events ); + } ) + ); + + const sessions = results + .filter( + ( result ): result is PromiseFulfilledResult< AiSessionSummary | undefined > => + result.status === 'fulfilled' + ) + .map( ( result ) => result.value ) + .filter( ( session ): session is AiSessionSummary => !! session ); + + return sessions.sort( ( a, b ) => Date.parse( b.updatedAt ) - Date.parse( a.updatedAt ) ); +} + +export async function loadAiSession( sessionIdOrPrefix: string ): Promise< LoadedAiSession > { + const summary = await resolveSessionByIdOrPrefix( sessionIdOrPrefix ); + const events = await readAiSessionEventsFromFile( summary.filePath ); + return { summary, events }; +} + +export async function deleteAiSession( sessionIdOrPrefix: string ): Promise< AiSessionSummary > { + const sessionToDelete = await resolveSessionByIdOrPrefix( sessionIdOrPrefix ); + await fs.rm( sessionToDelete.filePath, { force: false } ); + await pruneEmptySessionDirectories( path.dirname( sessionToDelete.filePath ) ); + + return sessionToDelete; +} diff --git a/apps/cli/ai/sessions/summary.ts b/apps/cli/ai/sessions/summary.ts new file mode 100644 index 0000000000..f613e63e87 --- /dev/null +++ b/apps/cli/ai/sessions/summary.ts @@ -0,0 +1,72 @@ +import fs from 'fs/promises'; +import { extractAiSessionIdFromFilePath } from './file-naming'; +import type { AiSessionEvent, AiSessionSummary } from './types'; + +export async function readAiSessionSummaryFromEvents( + filePath: string, + events: AiSessionEvent[] +): Promise< AiSessionSummary | undefined > { + if ( events.length === 0 ) { + return undefined; + } + + const linkedAgentSessionIds: string[] = []; + let createdAt: string | undefined; + let updatedAt: string | undefined; + let sessionId = extractAiSessionIdFromFilePath( filePath ); + let firstPrompt: string | undefined; + let selectedSiteName: string | undefined; + let endReason: 'error' | 'stopped' | undefined; + let eventCount = 0; + + for ( const event of events ) { + eventCount += 1; + updatedAt = event.timestamp; + + if ( event.type === 'session.started' ) { + createdAt = event.timestamp; + if ( event.sessionId.trim().length > 0 ) { + sessionId = event.sessionId; + } + } + + if ( + event.type === 'session.linked' && + ! linkedAgentSessionIds.includes( event.agentSessionId ) + ) { + linkedAgentSessionIds.push( event.agentSessionId ); + } + + if ( event.type === 'site.selected' ) { + selectedSiteName = event.siteName; + } + + if ( event.type === 'user.message' && event.source === 'prompt' && ! firstPrompt ) { + firstPrompt = event.text; + } + + if ( event.type === 'turn.closed' ) { + if ( event.status === 'error' ) { + endReason = 'error'; + } else if ( event.status === 'interrupted' ) { + endReason = 'stopped'; + } + } + } + + const stats = await fs.stat( filePath ); + const fallbackTimestamp = stats.mtime.toISOString(); + + return { + id: sessionId, + filePath, + createdAt: createdAt ?? fallbackTimestamp, + updatedAt: updatedAt ?? createdAt ?? fallbackTimestamp, + agentSessionId: linkedAgentSessionIds[ linkedAgentSessionIds.length - 1 ], + linkedAgentSessionIds, + firstPrompt, + selectedSiteName, + endReason, + eventCount, + }; +} diff --git a/apps/cli/ai/sessions/types.ts b/apps/cli/ai/sessions/types.ts new file mode 100644 index 0000000000..5bd38f7e7a --- /dev/null +++ b/apps/cli/ai/sessions/types.ts @@ -0,0 +1,79 @@ +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; + +export type TurnStatus = 'success' | 'error' | 'max_turns' | 'interrupted'; + +export type AiSessionEvent = + | { + type: 'session.started'; + timestamp: string; + version: 1; + sessionId: string; + } + | { + type: 'session.linked'; + timestamp: string; + agentSessionId: string; + } + | { + type: 'session.context'; + timestamp: string; + provider: string; + model: string; + } + | { + type: 'site.selected'; + timestamp: string; + siteName: string; + sitePath: string; + remote?: boolean; + url?: string; + } + | { + type: 'user.message'; + timestamp: string; + text: string; + source: 'prompt' | 'ask_user'; + sitePath?: string; + } + | { + type: 'sdk.message'; + timestamp: string; + message: SDKMessage; + } + | { + type: 'tool.progress'; + timestamp: string; + message: string; + } + | { + type: 'agent.question'; + timestamp: string; + question: string; + options: Array< { + label: string; + description: string; + } >; + } + | { + type: 'turn.closed'; + timestamp: string; + status: TurnStatus; + }; + +export interface AiSessionSummary { + id: string; + filePath: string; + createdAt: string; + updatedAt: string; + agentSessionId?: string; + linkedAgentSessionIds: string[]; + firstPrompt?: string; + selectedSiteName?: string; + endReason?: 'error' | 'stopped'; + eventCount: number; +} + +export interface LoadedAiSession { + summary: AiSessionSummary; + events: AiSessionEvent[]; +} diff --git a/apps/cli/ai/tests/ui.test.ts b/apps/cli/ai/tests/ui.test.ts new file mode 100644 index 0000000000..f1d8fcde95 --- /dev/null +++ b/apps/cli/ai/tests/ui.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AiChatUI } from 'cli/ai/ui'; +import { getSiteUrl, readAppdata } from 'cli/lib/appdata'; +import { openBrowser } from 'cli/lib/browser'; + +vi.mock( 'cli/lib/appdata', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('cli/lib/appdata') >(); + return { + ...actual, + readAppdata: vi.fn(), + getSiteUrl: vi.fn(), + }; +} ); + +vi.mock( 'cli/lib/browser', () => ( { + openBrowser: vi.fn(), +} ) ); + +describe( 'AiChatUI.openActiveSiteInBrowser', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'opens the restored active site when activeSiteData is missing', async () => { + const restoredSite = { + name: 'my-site', + path: '/Users/test/Studio/my-site', + running: false, + }; + + const siteData = { + name: 'my-site', + path: '/Users/test/Studio/my-site', + port: 8080, + }; + + const ui = Object.create( AiChatUI.prototype ) as { + openActiveSiteInBrowser: () => Promise< boolean >; + [ key: string ]: unknown; + }; + ui._activeSite = restoredSite; + ui._activeSiteData = null; + + vi.mocked( readAppdata ).mockResolvedValue( { + sites: [ siteData ], + } as never ); + vi.mocked( getSiteUrl ).mockReturnValue( 'http://localhost:8080' ); + + const opened = await ui.openActiveSiteInBrowser(); + + expect( opened ).toBe( true ); + expect( readAppdata ).toHaveBeenCalledTimes( 1 ); + expect( openBrowser ).toHaveBeenCalledWith( 'http://localhost:8080' ); + expect( ui._activeSiteData ).toEqual( siteData ); + } ); +} ); diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 08e513c0cb..ef2e917b02 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -449,6 +449,9 @@ export class AiChatUI { private activeExpandablePreview: ExpandablePreview | null = null; private _inAgentTurn = false; private _activeSiteData: SiteData | null = null; + private siteSelectedCallback: ( ( site: SiteInfo ) => void ) | null = null; + private replayMode = false; + private replayTimestampMs: number | null = null; private pendingToolCalls = new Map< string, { name: string; input: Record< string, unknown > } @@ -542,6 +545,55 @@ export class AiChatUI { return this._activeSite; } + set onSiteSelected( fn: ( ( site: SiteInfo ) => void ) | null ) { + this.siteSelectedCallback = fn; + } + + private nowMs(): number { + return this.replayTimestampMs ?? Date.now(); + } + + setReplayTimestamp( timestamp?: string ): void { + if ( ! this.replayMode ) { + return; + } + + if ( ! timestamp ) { + this.replayTimestampMs = null; + return; + } + + const parsedTimestamp = Date.parse( timestamp ); + this.replayTimestampMs = Number.isNaN( parsedTimestamp ) ? null : parsedTimestamp; + } + + prepareForReplay(): void { + this.replayMode = true; + this.replayTimestampMs = null; + this.hideLoader(); + this.currentMarkdown = null; + this.currentResponseText = ''; + } + + finishReplay(): void { + this.replayMode = false; + this.replayTimestampMs = null; + this.hideLoader(); + this.currentMarkdown = null; + this.currentResponseText = ''; + } + + showAgentQuestion( + question: string, + _options: Array< { label: string; description: string } > + ): void { + this.hideLoader(); + this.currentMarkdown = null; + this.currentResponseText = ''; + this.messages.addChild( new Text( '\n' + chalk.bold( question ), 0, 0 ) ); + this.tui.requestRender(); + } + constructor() { const terminal = new ProcessTerminal(); this.tui = new TUI( terminal ); @@ -892,12 +944,18 @@ export class AiChatUI { this.tui.requestRender(); } - private setActiveSite( site: SiteInfo ): void { + setActiveSite( site: SiteInfo, options: { announce?: boolean; emitEvent?: boolean } = {} ): void { + const { announce = true, emitEvent = true } = options; this._activeSite = site; this.editor.activeSiteName = site.name; const suffix = site.remote ? ' (WordPress.com)' : ''; const label = ` ✻ Selected site: ${ site.name }${ suffix }`; - this.messages.addChild( new Text( `${ chalk.hex( '#5b8db8' )( label ) }\n`, 0, 0 ) ); + if ( announce ) { + this.messages.addChild( new Text( `${ chalk.hex( '#5b8db8' )( label ) }\n`, 0, 0 ) ); + } + if ( emitEvent ) { + this.siteSelectedCallback?.( site ); + } this.tui.requestRender(); } @@ -1053,13 +1111,21 @@ export class AiChatUI { await openBrowser( this._activeSite.url ); return true; } - if ( ! this._activeSiteData ) { + if ( ! this._activeSite && ! this._activeSiteData ) { return false; } // Re-read appdata to get the current site state (port/domain may have changed) const appdata = await readAppdata(); - const freshSiteData = appdata.sites?.find( ( s ) => s.name === this._activeSite?.name ); + const activeSiteName = this._activeSite?.name ?? this._activeSiteData?.name; + const freshSiteData = appdata.sites?.find( ( site ) => site.name === activeSiteName ); const siteData = freshSiteData ?? this._activeSiteData; + if ( siteData ) { + this._activeSiteData = siteData; + } + if ( ! siteData ) { + return false; + } + const url = getSiteUrl( siteData ); if ( url ) { await openBrowser( url ); @@ -1303,7 +1369,7 @@ export class AiChatUI { this.currentResponseText = ''; this.hasShownResponseMarker = false; this.wasInterrupted = false; - this.turnStartTime = Date.now(); + this.turnStartTime = this.nowMs(); this.todoSnapshot = []; this.latestTodoSnapshot = []; this.lastRenderedTodoSignature = null; @@ -1468,6 +1534,10 @@ export class AiChatUI { this.toolDotText = new Text( '\n ' + '⏺' + ' ' + toolLabel, 0, 0 ); this.messages.addChild( this.toolDotText ); this.toolDotVisible = true; + if ( this.replayMode ) { + this.tui.requestRender(); + return; + } this.toolDotTimer = setInterval( () => { if ( ! this.toolDotText ) { return; @@ -1503,9 +1573,11 @@ export class AiChatUI { } private finalizeToolUseLine( isError: boolean, label: string ): void { - const elapsed = this.toolStartTime ? Date.now() - this.toolStartTime : 0; + const elapsed = this.toolStartTime ? this.nowMs() - this.toolStartTime : 0; this.toolStartTime = null; - const elapsedStr = elapsed > 0 ? chalk.dim( ` (${ ( elapsed / 1000 ).toFixed( 1 ) }s)` ) : ''; + const elapsedSeconds = Math.max( elapsed, 0 ) / 1000; + const elapsedStr = + elapsed > 0 || this.replayMode ? chalk.dim( ` (${ elapsedSeconds.toFixed( 1 ) }s)` ) : ''; const statusIcon = isError ? chalk.red( '⏺' ) : '⏺'; if ( this.toolDotText ) { @@ -1754,7 +1826,7 @@ export class AiChatUI { ); this.tui.requestRender(); } else if ( block.type === 'tool_use' ) { - this.toolStartTime = Date.now(); + this.toolStartTime = this.nowMs(); const typedBlock = block as { id: string; name: string; @@ -1790,7 +1862,7 @@ export class AiChatUI { } // Always show the loader after processing — the agent turn is still active // and more messages are coming (next API call, tool execution, etc.) - if ( ! this.loaderVisible ) { + if ( ! this.replayMode && ! this.loaderVisible ) { this.showLoader( this.randomThinkingMessage() ); } return undefined; @@ -1819,7 +1891,7 @@ export class AiChatUI { case 'result': { this.hideLoader(); if ( message.subtype === 'success' ) { - const thinkingSec = Math.round( ( Date.now() - this.turnStartTime ) / 1000 ); + const thinkingSec = Math.round( ( this.nowMs() - this.turnStartTime ) / 1000 ); if ( ! this.hasShownResponseMarker ) { this.messages.addChild( new Text( '\n ' + chalk.blue( '⏺' ) + ' Done', 0, 0 ) ); } @@ -1833,7 +1905,7 @@ export class AiChatUI { // User-initiated interruption: show friendly message instead of error if ( this.wasInterrupted ) { - const thinkingSec = Math.round( ( Date.now() - this.turnStartTime ) / 1000 ); + const thinkingSec = Math.round( ( this.nowMs() - this.turnStartTime ) / 1000 ); this.messages.addChild( new Text( '\n ' + chalk.yellow( '⏺' ) + ' ' + chalk.yellow( 'Interrupted' ), 0, 0 ) ); diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai/index.ts similarity index 59% rename from apps/cli/commands/ai.ts rename to apps/cli/commands/ai/index.ts index c2479a8f3c..8d4773f91b 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai/index.ts @@ -1,5 +1,11 @@ import { __ } from '@wordpress/i18n'; -import { AI_MODELS, DEFAULT_MODEL, startAiAgent, type AiModelId } from 'cli/ai/agent'; +import { + AI_MODELS, + DEFAULT_MODEL, + startAiAgent, + type AiModelId, + type AskUserQuestion, +} from 'cli/ai/agent'; import { getAvailableAiProviders, isAiProviderReady, @@ -10,6 +16,10 @@ import { saveSelectedAiProvider, } from 'cli/ai/auth'; import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers'; +import { resolveResumeSessionContext } from 'cli/ai/sessions/context'; +import { AiSessionRecorder } from 'cli/ai/sessions/recorder'; +import { replaySessionHistory } from 'cli/ai/sessions/replay'; +import { type LoadedAiSession, type TurnStatus } from 'cli/ai/sessions/types'; import { AI_CHAT_API_KEY_COMMAND, AI_CHAT_BROWSER_COMMAND, @@ -35,16 +45,116 @@ function isPromptAbortError( error: unknown ): boolean { ); } -export async function runCommand(): Promise< void > { +function getErrorMessage( error: unknown ): string { + if ( error instanceof Error ) { + return error.message; + } + + return String( error ); +} + +export async function runCommand( + options: { resumeSession?: LoadedAiSession; noSessionPersistence?: boolean } = {} +): Promise< void > { const ui = new AiChatUI(); - let currentProvider: AiProviderId = await resolveInitialAiProvider(); + const resumeContext = resolveResumeSessionContext( options.resumeSession ); + let currentProvider: AiProviderId = + resumeContext.provider ?? ( await resolveInitialAiProvider() ); + let currentModel: AiModelId = resumeContext.model ?? DEFAULT_MODEL; ui.currentProvider = currentProvider; - setProgressCallback( ( message ) => ui.setLoaderMessage( message ) ); + ui.currentModel = currentModel; ui.start(); ui.showWelcome(); - let sessionId: string | undefined; - let currentModel: AiModelId = DEFAULT_MODEL; + let sessionRecorder: AiSessionRecorder | undefined; + let didDisableSessionPersistence = options.noSessionPersistence === true; + let sessionId: string | undefined = resumeContext.sessionId; + let persistQueue: Promise< void > = Promise.resolve(); + + if ( options.noSessionPersistence ) { + ui.showInfo( 'Session persistence disabled (--no-session-persistence).' ); + } + + const ensureSessionRecorder = async (): Promise< AiSessionRecorder | undefined > => { + if ( didDisableSessionPersistence ) { + return undefined; + } + if ( sessionRecorder ) { + return sessionRecorder; + } + + try { + if ( options.resumeSession ) { + sessionRecorder = await AiSessionRecorder.open( { + sessionId: options.resumeSession.summary.id, + filePath: options.resumeSession.summary.filePath, + linkedAgentSessionIds: options.resumeSession.summary.linkedAgentSessionIds, + } ); + } else { + sessionRecorder = await AiSessionRecorder.create(); + } + } catch ( error ) { + didDisableSessionPersistence = true; + ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); + } + + return sessionRecorder; + }; + + const persist = ( callback: ( recorder: AiSessionRecorder ) => Promise< void > ) => { + persistQueue = persistQueue.then( async () => { + const recorder = await ensureSessionRecorder(); + if ( ! recorder ) { + return; + } + + try { + await callback( recorder ); + } catch ( error ) { + sessionRecorder = undefined; + if ( ! didDisableSessionPersistence ) { + didDisableSessionPersistence = true; + ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); + } + } + } ); + + return persistQueue; + }; + + async function persistSessionContext(): Promise< void > { + await persist( ( recorder ) => + recorder.recordSessionContext( { + provider: currentProvider, + model: currentModel, + } ) + ); + } + + setProgressCallback( ( message ) => { + const timestamp = new Date().toISOString(); + ui.setLoaderMessage( message ); + void persist( ( recorder ) => recorder.recordToolProgress( message, timestamp ) ); + } ); + + ui.onSiteSelected = ( site ) => { + void persist( ( recorder ) => + recorder.recordSiteSelected( { + name: site.name, + path: site.path, + remote: site.remote, + url: site.url, + } ) + ); + }; + + if ( options.resumeSession ) { + ui.showInfo( `Resuming session ${ options.resumeSession.summary.id }` ); + replaySessionHistory( ui, options.resumeSession.events ); + if ( ! sessionId ) { + ui.showInfo( 'No linked Claude session was found. Continuing from transcript only.' ); + } + } async function prepareProviderSelection( provider: AiProviderId, @@ -63,6 +173,7 @@ export async function runCommand(): Promise< void > { ui.currentProvider = currentProvider; sessionId = undefined; await saveSelectedAiProvider( currentProvider ); + await persistSessionContext(); if ( announce ) { ui.showInfo( `Switched to ${ AI_PROVIDERS[ currentProvider ] }` ); } @@ -110,6 +221,40 @@ export async function runCommand(): Promise< void > { ui.showInfo( 'No Anthropic API key saved. Use /provider to enter one.' ); } + async function askUserAndPersistAnswers( + questions: AskUserQuestion[] + ): Promise< Record< string, string > > { + for ( const question of questions ) { + await persist( ( recorder ) => + recorder.recordAgentQuestion( { + question: question.question, + options: question.options.map( ( option ) => ( { + label: option.label, + description: option.description, + } ) ), + } ) + ); + } + + const answers = await ui.askUser( questions ); + for ( const question of questions ) { + const answer = answers[ question.question ]; + if ( typeof answer !== 'string' || ! answer.trim() ) { + continue; + } + + await persist( ( recorder ) => + recorder.recordUserMessage( { + text: answer, + source: 'ask_user', + sitePath: ui.activeSite?.path, + } ) + ); + } + + return answers; + } + async function runAgentTurn( prompt: string ): Promise< void > { const env = await resolveAiEnvironment( currentProvider ); ui.beginAgentTurn(); @@ -126,12 +271,22 @@ export async function runCommand(): Promise< void > { }]\n\n${ prompt }`; } + await persistSessionContext(); + + await persist( ( recorder ) => + recorder.recordUserMessage( { + text: prompt, + source: 'prompt', + sitePath: site?.path, + } ) + ); + const agentQuery = startAiAgent( { prompt: enrichedPrompt, env, model: currentModel, resume: sessionId, - onAskUser: ( questions ) => ui.askUser( questions ), + onAskUser: ( questions ) => askUserAndPersistAnswers( questions ), } ); ui.onInterrupt = () => { @@ -139,22 +294,36 @@ export async function runCommand(): Promise< void > { }; let maxTurnsResult: { numTurns: number; costUsd: number } | undefined; + let turnStatus: TurnStatus = 'interrupted'; - for await ( const message of agentQuery ) { - const result = ui.handleMessage( message ); - if ( result ) { - sessionId = result.sessionId; - if ( 'maxTurnsReached' in result && result.maxTurnsReached ) { - maxTurnsResult = { - numTurns: result.numTurns, - costUsd: result.costUsd, - }; + try { + for await ( const message of agentQuery ) { + const timestamp = new Date().toISOString(); + const result = ui.handleMessage( message ); + await persist( ( recorder ) => recorder.recordSdkMessage( message, timestamp ) ); + if ( result ) { + sessionId = result.sessionId; + await persist( ( recorder ) => recorder.recordAgentSessionId( result.sessionId ) ); + + if ( 'maxTurnsReached' in result && result.maxTurnsReached ) { + maxTurnsResult = { + numTurns: result.numTurns, + costUsd: result.costUsd, + }; + turnStatus = 'max_turns'; + } else { + turnStatus = result.success ? 'success' : 'error'; + } } } + } catch ( error ) { + turnStatus = 'error'; + throw error; + } finally { + await persist( ( recorder ) => recorder.recordTurnClosed( turnStatus ) ); + ui.endAgentTurn(); } - ui.endAgentTurn(); - if ( maxTurnsResult ) { ui.showInfo( `Used ${ maxTurnsResult.numTurns } turns · $${ maxTurnsResult.costUsd.toFixed( 4 ) }` @@ -252,6 +421,7 @@ export async function runCommand(): Promise< void > { currentModel = newModel[ 0 ]; ui.currentModel = currentModel; ui.showInfo( `Switched to ${ AI_MODELS[ currentModel ] }` ); + await persistSessionContext(); } continue; } @@ -298,6 +468,7 @@ export async function runCommand(): Promise< void > { } } } finally { + await persistQueue; ui.stop(); process.exit( 0 ); } @@ -305,16 +476,26 @@ export async function runCommand(): Promise< void > { export const registerCommand = ( yargs: StudioArgv ) => { return yargs.command( { - command: 'ai', + command: '$0', describe: __( 'AI-powered WordPress assistant' ), builder: ( yargs ) => { - return yargs.option( 'path', { - hidden: true, - } ); + return yargs + .option( 'path', { + hidden: true, + } ) + .option( 'session-persistence', { + type: 'boolean', + default: true, + description: __( 'Record this AI chat session to disk' ), + } ); }, - handler: async () => { + handler: async ( argv ) => { try { - await runCommand(); + const noSessionPersistence = + ( argv as { sessionPersistence?: boolean } ).sessionPersistence === false; + await runCommand( { + noSessionPersistence, + } ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); diff --git a/apps/cli/commands/ai/sessions/delete.ts b/apps/cli/commands/ai/sessions/delete.ts new file mode 100644 index 0000000000..8ee4b690a6 --- /dev/null +++ b/apps/cli/commands/ai/sessions/delete.ts @@ -0,0 +1,59 @@ +import { __ } from '@wordpress/i18n'; +import { deleteAiSession, listAiSessions } from 'cli/ai/sessions/store'; +import { chooseSessionForAction } from 'cli/commands/ai/sessions/helpers'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const logger = new Logger< string >(); + +export async function runCommand( sessionIdOrPrefix?: string ): Promise< void > { + let resolvedSessionIdOrPrefix = sessionIdOrPrefix?.trim(); + + if ( ! resolvedSessionIdOrPrefix ) { + const selectedSession = await chooseSessionForAction( + __( 'Select a session to delete:' ), + __( 'No AI sessions found' ) + ); + if ( ! selectedSession ) { + return; + } + + resolvedSessionIdOrPrefix = selectedSession.id; + } + + if ( resolvedSessionIdOrPrefix.toLowerCase() === 'latest' ) { + const sessions = await listAiSessions(); + if ( sessions.length === 0 ) { + throw new Error( __( 'No AI sessions found' ) ); + } + + resolvedSessionIdOrPrefix = sessions[ 0 ].id; + } + + const deletedSession = await deleteAiSession( resolvedSessionIdOrPrefix ); + console.log( `${ __( 'Deleted AI session' ) }: ${ deletedSession.id }` ); +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'delete [id]', + describe: __( 'Delete an AI session (id, prefix, "latest", or picker)' ), + builder: ( deleteYargs ) => { + return deleteYargs.positional( 'id', { + type: 'string', + describe: __( 'Session id, id prefix, or "latest"' ), + } ); + }, + handler: async ( argv ) => { + try { + await runCommand( typeof argv.id === 'string' ? argv.id : undefined ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to delete AI session' ), error ) ); + } + } + }, + } ); +}; diff --git a/apps/cli/commands/ai/sessions/helpers.ts b/apps/cli/commands/ai/sessions/helpers.ts new file mode 100644 index 0000000000..b34678ec70 --- /dev/null +++ b/apps/cli/commands/ai/sessions/helpers.ts @@ -0,0 +1,207 @@ +import { select } from '@inquirer/prompts'; +import { truncateToWidth, visibleWidth } from '@mariozechner/pi-tui'; +import { __ } from '@wordpress/i18n'; +import chalk from 'chalk'; +import { listAiSessions } from 'cli/ai/sessions/store'; +import type { AiSessionSummary } from 'cli/ai/sessions/types'; + +function formatSessionTimestamp( timestamp: string ): string { + const parsed = Date.parse( timestamp ); + if ( Number.isNaN( parsed ) ) { + return timestamp; + } + + return new Date( parsed ).toISOString().replace( '.000Z', 'Z' ).replace( 'T', ' ' ); +} + +function getRelativeTime( timestamp: string ): string { + const targetMs = Date.parse( timestamp ); + if ( Number.isNaN( targetMs ) ) { + return formatSessionTimestamp( timestamp ); + } + + const diffMs = targetMs - Date.now(); + const absDiffMs = Math.abs( diffMs ); + const rtf = new Intl.RelativeTimeFormat( undefined, { numeric: 'auto' } ); + const units = [ + { unit: 'year', ms: 365 * 24 * 60 * 60 * 1000 }, + { unit: 'month', ms: 30 * 24 * 60 * 60 * 1000 }, + { unit: 'week', ms: 7 * 24 * 60 * 60 * 1000 }, + { unit: 'day', ms: 24 * 60 * 60 * 1000 }, + { unit: 'hour', ms: 60 * 60 * 1000 }, + { unit: 'minute', ms: 60 * 1000 }, + { unit: 'second', ms: 1000 }, + ] as const; + + for ( const { unit, ms } of units ) { + if ( absDiffMs >= ms || unit === 'second' ) { + const value = Math.round( diffMs / ms ); + return rtf.format( value, unit ); + } + } + + return formatSessionTimestamp( timestamp ); +} + +function toSingleLine( text: string ): string { + return text.replace( /\s+/g, ' ' ).trim(); +} + +function truncateWithEllipsis( text: string, maxLength: number ): string { + if ( maxLength <= 0 ) { + return ''; + } + if ( maxLength === 1 ) { + return '…'; + } + + return truncateToWidth( text, maxLength, '…' ); +} + +function padEndVisible( text: string, width: number ): string { + return `${ text }${ ' '.repeat( Math.max( 0, width - visibleWidth( text ) ) ) }`; +} + +function styleSessionId( id: string ): string { + return chalk.bold( id.slice( 0, 8 ) ) + chalk.dim( id.slice( 8 ) ); +} + +function formatSessionCompactLine( + session: AiSessionSummary, + terminalWidth: number, + layout: { idWidth: number; relativeWidth: number } +): string { + const relative = getRelativeTime( session.updatedAt ); + const prompt = toSingleLine( session.firstPrompt ?? __( '(No prompt yet)' ) ); + const separator = chalk.dim( ' • ' ); + const idText = padEndVisible( session.id, layout.idWidth ); + const relativeText = padEndVisible( relative, layout.relativeWidth ); + const hasStatusGlyph = session.endReason === 'error' || session.endReason === 'stopped'; + const statusPlainWidth = hasStatusGlyph ? 2 : 0; + const siteLabelPlain = session.selectedSiteName ? `✻ ${ session.selectedSiteName }` : ''; + const maxSiteLabelLength = Math.max( 8, Math.floor( terminalWidth * 0.25 ) ); + const siteLabel = truncateWithEllipsis( siteLabelPlain, maxSiteLabelLength ); + const suffixWidth = siteLabel ? visibleWidth( siteLabel ) : 0; + const gapWidth = siteLabel ? 2 : 0; + const prefixPlain = `${ idText } • ${ relativeText } • `; + const maxPromptLength = Math.max( + 1, + terminalWidth - visibleWidth( prefixPlain ) - statusPlainWidth - gapWidth - suffixWidth - 1 + ); + const abstract = truncateWithEllipsis( prompt, maxPromptLength ); + const promptStyled = session.firstPrompt ? chalk.white( abstract ) : chalk.dim( abstract ); + + const statusGlyph = + session.endReason === 'error' + ? chalk.red( '✕ ' ) + : session.endReason === 'stopped' + ? chalk.gray( '◌ ' ) + : ''; + const leftPlain = `${ idText } • ${ relativeText } • ${ statusGlyph ? 'x ' : '' }${ abstract }`; + const renderedLeft = `${ styleSessionId( session.id ) }${ ' '.repeat( + Math.max( 0, layout.idWidth - visibleWidth( session.id ) ) + ) }${ separator }${ chalk.cyan( relative ) }${ ' '.repeat( + Math.max( 0, layout.relativeWidth - visibleWidth( relative ) ) + ) }${ separator }${ statusGlyph }${ promptStyled }`; + if ( ! siteLabel ) { + return renderedLeft; + } + + const padding = Math.max( + 2, + terminalWidth - visibleWidth( leftPlain ) - visibleWidth( siteLabel ) - 1 + ); + return `${ renderedLeft }${ ' '.repeat( padding ) }${ chalk.hex( '#8839ef' )( siteLabel ) }`; +} + +export function displaySessionsCompact( sessions: AiSessionSummary[] ): void { + const terminalWidth = Math.max( process.stdout.columns ?? 100, 60 ); + const relativeTimes = sessions.map( ( session ) => getRelativeTime( session.updatedAt ) ); + const layout = { + idWidth: Math.max( ...sessions.map( ( session ) => visibleWidth( session.id ) ) ), + relativeWidth: Math.max( ...relativeTimes.map( ( value ) => visibleWidth( value ) ) ), + }; + + console.log( + chalk.bold( __( 'AI Sessions' ) ) + + chalk.dim( ` (${ sessions.length })` ) + + chalk.dim( ` · ${ __( 'Most recent first' ) }` ) + ); + + for ( const session of sessions ) { + console.log( formatSessionCompactLine( session, terminalWidth, layout ) ); + } +} + +async function pickSessionInteractively( + sessions: AiSessionSummary[], + message: string +): Promise< AiSessionSummary | undefined > { + const interactiveWidth = Math.max( ( process.stdout.columns ?? 100 ) - 4, 56 ); + const abortController = new AbortController(); + const handleEscKey = ( chunk: Buffer | string ) => { + const bytes = Buffer.isBuffer( chunk ) ? chunk : Buffer.from( chunk ); + if ( bytes.length === 1 && bytes[ 0 ] === 0x1b ) { + abortController.abort(); + } + }; + + if ( process.stdin.isTTY ) { + process.stdin.on( 'data', handleEscKey ); + } + + try { + const selectedSessionId = await select( + { + message, + choices: sessions.map( ( session ) => ( { + name: formatSessionCompactLine( session, interactiveWidth, { + idWidth: Math.max( ...sessions.map( ( s ) => visibleWidth( s.id ) ) ), + relativeWidth: Math.max( + ...sessions.map( ( s ) => visibleWidth( getRelativeTime( s.updatedAt ) ) ) + ), + } ), + value: session.id, + } ) ), + pageSize: Math.min( 12, sessions.length ), + loop: false, + theme: { + style: { + keysHelpTip: () => chalk.dim( '↑↓ navigate · ⏎ select · esc cancel' ), + }, + }, + }, + { + signal: abortController.signal, + } + ); + + return sessions.find( ( session ) => session.id === selectedSessionId ); + } catch ( error ) { + if ( + error instanceof Error && + ( error.name === 'AbortPromptError' || error.name === 'ExitPromptError' ) + ) { + return undefined; + } + + throw error; + } finally { + if ( process.stdin.isTTY ) { + process.stdin.off( 'data', handleEscKey ); + } + } +} + +export async function chooseSessionForAction( + actionLabel: string, + noSessionsMessage: string +): Promise< AiSessionSummary | undefined > { + const sessions = await listAiSessions(); + if ( sessions.length === 0 ) { + console.log( noSessionsMessage ); + return undefined; + } + + return pickSessionInteractively( sessions, actionLabel ); +} diff --git a/apps/cli/commands/ai/sessions/list.ts b/apps/cli/commands/ai/sessions/list.ts new file mode 100644 index 0000000000..5cf337419a --- /dev/null +++ b/apps/cli/commands/ai/sessions/list.ts @@ -0,0 +1,51 @@ +import { __ } from '@wordpress/i18n'; +import { listAiSessions } from 'cli/ai/sessions/store'; +import { displaySessionsCompact } from 'cli/commands/ai/sessions/helpers'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const logger = new Logger< string >(); + +type SessionOutputFormat = 'compact' | 'json'; + +export async function runCommand( format: SessionOutputFormat ): Promise< void > { + const sessions = await listAiSessions(); + + if ( sessions.length === 0 ) { + console.log( __( 'No AI sessions found' ) ); + return; + } + + if ( format === 'json' ) { + console.log( JSON.stringify( sessions, null, 2 ) ); + return; + } + + displaySessionsCompact( sessions ); +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'list', + describe: __( 'List AI sessions' ), + builder: ( listYargs ) => { + return listYargs.option( 'format', { + type: 'string', + choices: [ 'compact', 'json' ] as const, + default: 'compact' as const, + description: __( 'Output format' ), + } ); + }, + handler: async ( argv ) => { + try { + await runCommand( argv.format as SessionOutputFormat ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to list AI sessions' ), error ) ); + } + } + }, + } ); +}; diff --git a/apps/cli/commands/ai/sessions/resume.ts b/apps/cli/commands/ai/sessions/resume.ts new file mode 100644 index 0000000000..b33c215547 --- /dev/null +++ b/apps/cli/commands/ai/sessions/resume.ts @@ -0,0 +1,71 @@ +import { __ } from '@wordpress/i18n'; +import { listAiSessions, loadAiSession } from 'cli/ai/sessions/store'; +import { runCommand as runAiCommand } from 'cli/commands/ai'; +import { chooseSessionForAction } from 'cli/commands/ai/sessions/helpers'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const logger = new Logger< string >(); + +export async function runCommand( + sessionIdOrPrefix?: string, + options: { noSessionPersistence?: boolean } = {} +): Promise< void > { + let resolvedSessionIdOrPrefix = sessionIdOrPrefix?.trim(); + + if ( ! resolvedSessionIdOrPrefix ) { + const selectedSession = await chooseSessionForAction( + __( 'Select a session to resume:' ), + __( 'No AI sessions found' ) + ); + if ( ! selectedSession ) { + return; + } + + resolvedSessionIdOrPrefix = selectedSession.id; + } + + if ( resolvedSessionIdOrPrefix.toLowerCase() === 'latest' ) { + const sessions = await listAiSessions(); + if ( sessions.length === 0 ) { + throw new Error( __( 'No AI sessions found' ) ); + } + + resolvedSessionIdOrPrefix = sessions[ 0 ].id; + } + + const session = await loadAiSession( resolvedSessionIdOrPrefix ); + await runAiCommand( { + resumeSession: session, + noSessionPersistence: options.noSessionPersistence === true, + } ); +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'resume [id]', + describe: __( 'Resume an AI session (id, prefix, "latest", or picker)' ), + builder: ( resumeYargs ) => { + return resumeYargs.positional( 'id', { + type: 'string', + describe: __( 'Session id, id prefix, or "latest"' ), + } ); + }, + handler: async ( argv ) => { + try { + const noSessionPersistence = + ( argv as { sessionPersistence?: boolean } ).sessionPersistence === false; + await runCommand( typeof argv.id === 'string' ? argv.id : undefined, { + noSessionPersistence, + } ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to resume AI session' ), error ); + logger.reportError( loggerError ); + } + } + }, + } ); +}; diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts new file mode 100644 index 0000000000..a93dead792 --- /dev/null +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -0,0 +1,470 @@ +import { __ } from '@wordpress/i18n'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import yargs from 'yargs/yargs'; +import { AI_MODELS, DEFAULT_MODEL, startAiAgent } from 'cli/ai/agent'; +import { + resolveAiEnvironment, + resolveInitialAiProvider, + resolveUnavailableAiProvider, +} from 'cli/ai/auth'; +import { AI_PROVIDERS } from 'cli/ai/providers'; +import { AiSessionRecorder } from 'cli/ai/sessions/recorder'; +import { deleteAiSession, listAiSessions, loadAiSession } from 'cli/ai/sessions/store'; +import { registerCommand as registerAiCommand } from 'cli/commands/ai'; +import { registerCommand as registerAiSessionsDeleteCommand } from 'cli/commands/ai/sessions/delete'; +import { registerCommand as registerAiSessionsListCommand } from 'cli/commands/ai/sessions/list'; +import { registerCommand as registerAiSessionsResumeCommand } from 'cli/commands/ai/sessions/resume'; +import { getAnthropicApiKey } from 'cli/lib/appdata'; +import { StudioArgv } from 'cli/types'; + +const { askUserMock, recordSessionContextMock, reportErrorMock, waitForInputMock } = vi.hoisted( + () => ( { + askUserMock: vi.fn(), + recordSessionContextMock: vi.fn(), + reportErrorMock: vi.fn(), + waitForInputMock: vi.fn(), + } ) +); + +vi.mock( 'cli/lib/appdata', async () => { + const actual = await vi.importActual< typeof import('cli/lib/appdata') >( 'cli/lib/appdata' ); + + return { + ...actual, + getAnthropicApiKey: vi.fn(), + getAuthToken: vi.fn().mockResolvedValue( { + displayName: 'Test User', + email: 'test@example.com', + } ), + saveAnthropicApiKey: vi.fn(), + }; +} ); + +vi.mock( 'cli/ai/auth', () => ( { + getAvailableAiProviders: vi.fn().mockResolvedValue( [ 'anthropic-api-key', 'wpcom' ] ), + isAiProviderReady: vi.fn().mockResolvedValue( true ), + prepareAiProvider: vi.fn().mockResolvedValue( undefined ), + resolveAiEnvironment: vi.fn().mockResolvedValue( {} ), + resolveInitialAiProvider: vi.fn().mockResolvedValue( 'anthropic-api-key' ), + resolveUnavailableAiProvider: vi.fn().mockResolvedValue( undefined ), + saveSelectedAiProvider: vi.fn().mockResolvedValue( undefined ), +} ) ); + +vi.mock( 'cli/logger', () => ( { + Logger: class { + reportStart = vi.fn(); + reportSuccess = vi.fn(); + reportError = reportErrorMock; + reportProgress = vi.fn(); + reportWarning = vi.fn(); + reportKeyValuePair = vi.fn(); + spinner = {}; + currentAction = null; + }, + LoggerError: class LoggerError extends Error { + previousError?: Error; + constructor( message: string, previousError?: unknown ) { + super( + previousError instanceof Error ? `${ message }: ${ previousError.message }` : message + ); + this.name = 'LoggerError'; + if ( previousError instanceof Error ) { + this.previousError = previousError; + } + } + }, + setProgressCallback: vi.fn(), +} ) ); + +vi.mock( 'cli/ai/agent', async () => { + const actual = await vi.importActual< typeof import('cli/ai/agent') >( 'cli/ai/agent' ); + const emptyQuery = { + interrupt: vi.fn().mockResolvedValue( undefined ), + [ Symbol.asyncIterator ]() { + return { + next: async () => ( { + done: true as const, + value: undefined, + } ), + }; + }, + }; + + return { + ...actual, + startAiAgent: vi.fn( () => emptyQuery ), + }; +} ); + +vi.mock( 'cli/ai/ui', () => ( { + AiChatUI: class { + activeSite: { name: string; path: string; running: boolean } | null = null; + currentModel = 'claude-sonnet-4-6'; + onSiteSelected: ( ( site: { name: string; path: string; running: boolean } ) => void ) | null = + null; + onInterrupt: ( () => void ) | null = null; + start() {} + stop() {} + showWelcome() {} + showInfo() {} + showError() {} + setStatusMessage() {} + prepareForReplay() {} + finishReplay() {} + beginAgentTurn() {} + endAgentTurn() {} + setLoaderMessage() {} + setActiveSite( site: { name: string; path: string; running: boolean } ) { + this.activeSite = site; + } + addUserMessage() {} + handleMessage() { + return undefined; + } + showAgentQuestion() {} + async askUser() { + return askUserMock(); + } + async waitForInput() { + return waitForInputMock(); + } + }, + getToolDetail: ( _name: string, input: Record< string, unknown > ) => + typeof input.detail === 'string' ? input.detail : '', +} ) ); + +vi.mock( 'cli/ai/sessions/recorder', () => { + class MockAiSessionRecorder { + static create = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); + static open = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); + async recordSdkMessage() {} + async recordToolProgress() {} + async recordSessionContext( ...args: unknown[] ) { + return recordSessionContextMock( ...args ); + } + async recordSiteSelected() {} + async recordUserMessage() {} + async recordAgentQuestion() {} + async recordTurnClosed() {} + async recordAgentSessionId() {} + } + + return { + AiSessionRecorder: MockAiSessionRecorder, + }; +} ); + +vi.mock( 'cli/ai/sessions/store', () => ( { + listAiSessions: vi.fn(), + loadAiSession: vi.fn(), + deleteAiSession: vi.fn(), +} ) ); + +vi.mock( 'cli/ai/sessions/replay', () => ( { + replaySessionHistory: vi.fn(), +} ) ); + +vi.mock( 'cli/commands/auth/login', () => ( { + runCommand: vi.fn().mockResolvedValue( undefined ), +} ) ); + +vi.mock( 'cli/commands/auth/logout', () => ( { + runCommand: vi.fn().mockResolvedValue( undefined ), +} ) ); + +describe( 'CLI: studio ai sessions command', () => { + beforeEach( () => { + vi.clearAllMocks(); + vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'test-api-key' ); + askUserMock.mockResolvedValue( {} ); + waitForInputMock.mockResolvedValue( '/exit' ); + vi.spyOn( process, 'exit' ).mockImplementation( () => undefined as never ); + } ); + + function buildParser(): StudioArgv { + const parser = yargs( [] ).scriptName( 'studio' ).strict().exitProcess( false ) as StudioArgv; + parser.command( 'ai', __( 'AI-powered WordPress assistant' ), ( aiYargs ) => { + registerAiCommand( aiYargs as StudioArgv ); + aiYargs.command( 'sessions', __( 'Manage AI sessions' ), ( sessionsYargs ) => { + sessionsYargs + .option( 'path', { + hidden: true, + } ) + .option( 'session-persistence', { + type: 'boolean', + default: true, + description: __( 'Record this AI chat session to disk' ), + } ); + registerAiSessionsListCommand( sessionsYargs as StudioArgv ); + registerAiSessionsResumeCommand( sessionsYargs as StudioArgv ); + registerAiSessionsDeleteCommand( sessionsYargs as StudioArgv ); + sessionsYargs + .version( false ) + .demandCommand( 1, __( 'You must provide a valid ai sessions command' ) ); + } ); + aiYargs.version( false ); + } ); + return parser; + } + + it( 'does not record an empty session when running studio ai and exiting immediately', async () => { + await buildParser().parseAsync( [ 'ai' ] ); + + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).create ).not.toHaveBeenCalled(); + } ); + + it( 'records sessions by default once a prompt is submitted', async () => { + waitForInputMock.mockResolvedValueOnce( 'Hello' ).mockResolvedValueOnce( '/exit' ); + + await buildParser().parseAsync( [ 'ai' ] ); + + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).create ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'persists selected model before the first prompt is sent', async () => { + const alternateModel = ( Object.entries( AI_MODELS ) as [ string, string ][] ).find( + ( [ modelId ] ) => modelId !== DEFAULT_MODEL + ); + expect( alternateModel ).toBeDefined(); + const [ selectedModelId, selectedModelLabel ] = alternateModel!; + + waitForInputMock.mockResolvedValueOnce( '/model' ).mockResolvedValueOnce( '/exit' ); + askUserMock.mockResolvedValueOnce( { + 'Select a model': selectedModelLabel, + } ); + + await buildParser().parseAsync( [ 'ai' ] ); + + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).create ).toHaveBeenCalledTimes( 1 ); + expect( recordSessionContextMock ).toHaveBeenCalledWith( { + provider: 'anthropic-api-key', + model: selectedModelId, + } ); + expect( startAiAgent ).not.toHaveBeenCalled(); + } ); + + it( 'persists selected provider before the first prompt is sent', async () => { + waitForInputMock.mockResolvedValueOnce( '/provider' ).mockResolvedValueOnce( '/exit' ); + askUserMock.mockResolvedValueOnce( { + 'Select an AI provider': AI_PROVIDERS.wpcom, + } ); + + await buildParser().parseAsync( [ 'ai' ] ); + + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).create ).toHaveBeenCalledTimes( 1 ); + expect( recordSessionContextMock ).toHaveBeenCalledWith( { + provider: 'wpcom', + model: DEFAULT_MODEL, + } ); + expect( startAiAgent ).not.toHaveBeenCalled(); + } ); + + it( 'persists auto-switched provider after logout before any prompt is sent', async () => { + vi.mocked( resolveUnavailableAiProvider ).mockResolvedValueOnce( 'wpcom' ); + waitForInputMock.mockResolvedValueOnce( '/logout' ).mockResolvedValueOnce( '/exit' ); + + await buildParser().parseAsync( [ 'ai' ] ); + + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).create ).toHaveBeenCalledTimes( 1 ); + expect( recordSessionContextMock ).toHaveBeenCalledWith( { + provider: 'wpcom', + model: DEFAULT_MODEL, + } ); + expect( startAiAgent ).not.toHaveBeenCalled(); + } ); + + it( 'disables session recording with --no-session-persistence', async () => { + await buildParser().parseAsync( [ 'ai', '--no-session-persistence' ] ); + + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).create ).not.toHaveBeenCalled(); + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).open ).not.toHaveBeenCalled(); + } ); + + it( 'resumes the latest session', async () => { + vi.mocked( listAiSessions ).mockResolvedValue( [ + { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + }, + { + id: 'session-older', + filePath: '/tmp/session-older.jsonl', + createdAt: '2026-03-10T11:00:00.000Z', + updatedAt: '2026-03-10T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + }, + ] ); + vi.mocked( loadAiSession ).mockResolvedValue( { + summary: { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + }, + events: [], + } ); + + await buildParser().parseAsync( [ 'ai', 'sessions', 'resume', 'latest' ] ); + + expect( loadAiSession ).toHaveBeenCalledWith( 'session-latest' ); + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).open ).not.toHaveBeenCalled(); + expect( process.exit ).toHaveBeenCalledWith( 0 ); + } ); + + it( 'deletes the latest session', async () => { + vi.mocked( listAiSessions ).mockResolvedValue( [ + { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + }, + { + id: 'session-older', + filePath: '/tmp/session-older.jsonl', + createdAt: '2026-03-10T11:00:00.000Z', + updatedAt: '2026-03-10T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + }, + ] ); + vi.mocked( deleteAiSession ).mockResolvedValue( { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + } ); + + await buildParser().parseAsync( [ 'ai', 'sessions', 'delete', 'latest' ] ); + + expect( deleteAiSession ).toHaveBeenCalledWith( 'session-latest' ); + expect( console.log ).toHaveBeenCalledWith( expect.stringContaining( 'session-latest' ) ); + } ); + + it( 'resumes latest without persistence when --no-session-persistence is set', async () => { + vi.mocked( listAiSessions ).mockResolvedValue( [ + { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + }, + ] ); + vi.mocked( loadAiSession ).mockResolvedValue( { + summary: { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 1, + }, + events: [], + } ); + + await buildParser().parseAsync( [ + 'ai', + 'sessions', + 'resume', + 'latest', + '--no-session-persistence', + ] ); + + expect( loadAiSession ).toHaveBeenCalledWith( 'session-latest' ); + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).open ).not.toHaveBeenCalled(); + expect( process.exit ).toHaveBeenCalledWith( 0 ); + } ); + + it( 'reports an error when resuming latest and no sessions exist', async () => { + vi.mocked( listAiSessions ).mockResolvedValue( [] ); + + await buildParser().parseAsync( [ 'ai', 'sessions', 'resume', 'latest' ] ); + + expect( loadAiSession ).not.toHaveBeenCalled(); + expect( reportErrorMock ).toHaveBeenCalled(); + } ); + + it( 'reports an error when deleting latest and no sessions exist', async () => { + vi.mocked( listAiSessions ).mockResolvedValue( [] ); + + await buildParser().parseAsync( [ 'ai', 'sessions', 'delete', 'latest' ] ); + + expect( deleteAiSession ).not.toHaveBeenCalled(); + expect( reportErrorMock ).toHaveBeenCalled(); + } ); + + it( 'restores provider, model, and resume session id from session events', async () => { + vi.mocked( resolveInitialAiProvider ).mockResolvedValue( 'wpcom' ); + waitForInputMock.mockResolvedValueOnce( 'Continue the task' ).mockResolvedValueOnce( '/exit' ); + + vi.mocked( listAiSessions ).mockResolvedValue( [ + { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 2, + }, + ] ); + vi.mocked( loadAiSession ).mockResolvedValue( { + summary: { + id: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + createdAt: '2026-03-11T11:00:00.000Z', + updatedAt: '2026-03-11T11:00:00.000Z', + linkedAgentSessionIds: [], + eventCount: 2, + }, + events: [ + { + type: 'session.context', + timestamp: '2026-03-11T11:00:00.000Z', + provider: 'anthropic-api-key', + model: 'claude-opus-4-6', + }, + { + type: 'sdk.message', + timestamp: '2026-03-11T11:00:01.000Z', + message: { + type: 'assistant', + session_id: 'session-from-sdk-message', + message: { + content: [], + }, + }, + }, + ], + } ); + + await buildParser().parseAsync( [ 'ai', 'sessions', 'resume', 'latest' ] ); + + expect( resolveAiEnvironment ).toHaveBeenCalledWith( 'anthropic-api-key' ); + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).open ).toHaveBeenCalledWith( + expect.objectContaining( { + sessionId: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + } ) + ); + expect( startAiAgent ).toHaveBeenCalledWith( + expect.objectContaining( { + model: 'claude-opus-4-6', + resume: 'session-from-sdk-message', + } ) + ); + expect( process.exit ).toHaveBeenCalledWith( 0 ); + } ); +} ); diff --git a/apps/cli/commands/ai/tests/sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts new file mode 100644 index 0000000000..6d324b1dc9 --- /dev/null +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -0,0 +1,521 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getAiSessionsDirectoryForDate, getAiSessionsRootDirectory } from 'cli/ai/sessions/paths'; +import { AiSessionRecorder } from 'cli/ai/sessions/recorder'; +import { replaySessionHistory } from 'cli/ai/sessions/replay'; +import { + deleteAiSession, + listAiSessions, + loadAiSession, + readAiSessionEventsFromFile, +} from 'cli/ai/sessions/store'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { AiSessionEvent } from 'cli/ai/sessions/types'; + +describe( 'ai-sessions', () => { + let testRoot: string | undefined; + + afterEach( async () => { + delete process.env.E2E; + delete process.env.E2E_APP_DATA_PATH; + + if ( testRoot ) { + await fs.rm( testRoot, { recursive: true, force: true } ); + testRoot = undefined; + } + } ); + + it( 'stores minimal conversation events as jsonl with explicit timestamp fields', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const startedAt = new Date( '2026-03-11T10:00:00.000Z' ); + const recorder = await AiSessionRecorder.create( { startedAt } ); + + await recorder.recordSiteSelected( { + name: 'My WordPress Website', + path: '/tmp/my-wordpress-website', + } ); + await recorder.recordUserMessage( { + text: 'Help me create a plugin', + source: 'prompt', + sitePath: '/tmp/my-wordpress-website', + } ); + await recorder.recordSessionContext( { + provider: 'anthropic-api-key', + model: 'claude-sonnet-4-6', + } ); + await recorder.recordSdkMessage( { + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: 'Sure, I can help with that.', + }, + ], + }, + } as SDKMessage ); + await recorder.recordToolProgress( 'Starting process daemon…' ); + await recorder.recordAgentQuestion( { + question: 'Choose a plugin slug', + options: [ + { + label: 'my-plugin', + description: 'Use default slug', + }, + ], + } ); + await recorder.recordUserMessage( { + text: 'my-plugin', + source: 'ask_user', + } ); + await recorder.recordAgentSessionId( 'agent-session-1' ); + await recorder.recordTurnClosed( 'success' ); + + const events = await readAiSessionEventsFromFile( recorder.filePath ); + expect( recorder.filePath.startsWith( getAiSessionsDirectoryForDate( startedAt ) ) ).toBe( + true + ); + const fileName = path.basename( recorder.filePath ); + expect( fileName ).toBe( `2026-03-11T10-00-00-${ recorder.sessionId }.jsonl` ); + expect( events[ 0 ] ).toMatchObject( { + type: 'session.started', + version: 1, + sessionId: recorder.sessionId, + timestamp: startedAt.toISOString(), + } ); + expect( events.find( ( event ) => event.type === 'user.message' ) ).toMatchObject( { + type: 'user.message', + source: 'prompt', + text: 'Help me create a plugin', + } ); + expect( events.find( ( event ) => event.type === 'sdk.message' ) ).toMatchObject( { + type: 'sdk.message', + message: { + type: 'assistant', + }, + } ); + expect( events.find( ( event ) => event.type === 'session.context' ) ).toMatchObject( { + type: 'session.context', + provider: 'anthropic-api-key', + model: 'claude-sonnet-4-6', + } ); + expect( events.find( ( event ) => event.type === 'tool.progress' ) ).toMatchObject( { + type: 'tool.progress', + message: 'Starting process daemon…', + } ); + expect( events.find( ( event ) => event.type === 'turn.closed' ) ).toMatchObject( { + type: 'turn.closed', + status: 'success', + } ); + + const hasShortTimestampKey = events.some( + ( event ) => typeof event === 'object' && event && 't' in event + ); + expect( hasShortTimestampKey ).toBe( false ); + } ); + + it( 'deduplicates linked agent session ids', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const recorder = await AiSessionRecorder.create(); + await recorder.recordAgentSessionId( 'agent-session-1' ); + await recorder.recordAgentSessionId( 'agent-session-1' ); + await recorder.recordAgentSessionId( 'agent-session-2' ); + + const events = await readAiSessionEventsFromFile( recorder.filePath ); + const linkedEvents = events.filter( ( event ) => event.type === 'session.linked' ); + expect( linkedEvents ).toHaveLength( 2 ); + expect( linkedEvents ).toMatchObject( [ + { + type: 'session.linked', + agentSessionId: 'agent-session-1', + }, + { + type: 'session.linked', + agentSessionId: 'agent-session-2', + }, + ] ); + } ); + + it( 'persists remote site metadata in site.selected events', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const recorder = await AiSessionRecorder.create(); + await recorder.recordSiteSelected( { + name: 'my-remote-site', + path: '', + remote: true, + url: 'https://my-remote-site.wordpress.com', + } ); + + const events = await readAiSessionEventsFromFile( recorder.filePath ); + expect( events.find( ( event ) => event.type === 'site.selected' ) ).toMatchObject( { + type: 'site.selected', + siteName: 'my-remote-site', + sitePath: '', + remote: true, + url: 'https://my-remote-site.wordpress.com', + } ); + } ); + + it( 'loads sessions by id prefix with linked Claude session metadata', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const recorder = await AiSessionRecorder.create(); + await recorder.recordUserMessage( { + text: 'Hello there', + source: 'prompt', + } ); + await recorder.recordAgentSessionId( 'agent-session-123' ); + await recorder.recordTurnClosed( 'success' ); + + const prefix = recorder.sessionId.slice( 0, 8 ); + const loadedSession = await loadAiSession( prefix ); + expect( loadedSession.summary.id ).toBe( recorder.sessionId ); + expect( loadedSession.summary.agentSessionId ).toBe( 'agent-session-123' ); + expect( loadedSession.summary.linkedAgentSessionIds ).toEqual( [ 'agent-session-123' ] ); + expect( loadedSession.events.some( ( event ) => event.type === 'turn.closed' ) ).toBe( true ); + } ); + + it( 'opens an existing session recorder and appends to the same file', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const recorder = await AiSessionRecorder.create(); + await recorder.recordAgentSessionId( 'agent-session-1' ); + await recorder.recordUserMessage( { + text: 'First message', + source: 'prompt', + } ); + + const reopenedRecorder = await AiSessionRecorder.open( { + sessionId: recorder.sessionId, + filePath: recorder.filePath, + linkedAgentSessionIds: [ 'agent-session-1' ], + } ); + await reopenedRecorder.recordTurnClosed( 'success' ); + + const sessions = await listAiSessions(); + expect( sessions ).toHaveLength( 1 ); + expect( sessions[ 0 ] ).toMatchObject( { + id: recorder.sessionId, + filePath: recorder.filePath, + agentSessionId: 'agent-session-1', + linkedAgentSessionIds: [ 'agent-session-1' ], + } ); + + const events = await readAiSessionEventsFromFile( recorder.filePath ); + expect( events[ events.length - 1 ] ).toMatchObject( { + type: 'turn.closed', + status: 'success', + } ); + } ); + + it( 'builds list summaries with prompt, selected site, end reason, and event count', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const recorder = await AiSessionRecorder.create(); + await recorder.recordSiteSelected( { + name: 'My WordPress Website', + path: '/tmp/my-wordpress-website', + } ); + await recorder.recordUserMessage( { + text: 'Create a homepage for me', + source: 'prompt', + } ); + await recorder.recordTurnClosed( 'interrupted' ); + + const sessions = await listAiSessions(); + expect( sessions ).toHaveLength( 1 ); + expect( sessions[ 0 ] ).toMatchObject( { + id: recorder.sessionId, + firstPrompt: 'Create a homepage for me', + selectedSiteName: 'My WordPress Website', + endReason: 'stopped', + } ); + expect( sessions[ 0 ].eventCount ).toBeGreaterThanOrEqual( 4 ); + } ); + + it( 'deletes a session by id prefix and prunes empty date directories', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const startedAt = new Date( '2026-03-11T10:00:00.000Z' ); + const recorder = await AiSessionRecorder.create( { startedAt } ); + await recorder.recordUserMessage( { + text: 'Delete me', + source: 'prompt', + } ); + + const deleted = await deleteAiSession( recorder.sessionId.slice( 0, 8 ) ); + expect( deleted.id ).toBe( recorder.sessionId ); + + const sessions = await listAiSessions(); + expect( sessions ).toHaveLength( 0 ); + + const dayDirectory = getAiSessionsDirectoryForDate( startedAt ); + const monthDirectory = path.dirname( dayDirectory ); + const yearDirectory = path.dirname( monthDirectory ); + await expect( fs.access( dayDirectory ) ).rejects.toThrow(); + await expect( fs.access( monthDirectory ) ).rejects.toThrow(); + await expect( fs.access( yearDirectory ) ).rejects.toThrow(); + + await expect( fs.access( getAiSessionsRootDirectory() ) ).resolves.not.toThrow(); + } ); + + it( 'keeps date directories when deleting one of multiple sessions from the same day', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const firstStart = new Date( '2026-03-11T10:00:00.000Z' ); + const secondStart = new Date( '2026-03-11T11:00:00.000Z' ); + const firstRecorder = await AiSessionRecorder.create( { startedAt: firstStart } ); + const secondRecorder = await AiSessionRecorder.create( { startedAt: secondStart } ); + + await firstRecorder.recordUserMessage( { + text: 'Delete me', + source: 'prompt', + } ); + await secondRecorder.recordUserMessage( { + text: 'Keep me', + source: 'prompt', + } ); + + await deleteAiSession( firstRecorder.sessionId ); + + const sessions = await listAiSessions(); + expect( sessions ).toHaveLength( 1 ); + expect( sessions[ 0 ].id ).toBe( secondRecorder.sessionId ); + + const dayDirectory = getAiSessionsDirectoryForDate( firstStart ); + const monthDirectory = path.dirname( dayDirectory ); + const yearDirectory = path.dirname( monthDirectory ); + + await expect( fs.access( dayDirectory ) ).resolves.not.toThrow(); + await expect( fs.access( monthDirectory ) ).resolves.not.toThrow(); + await expect( fs.access( yearDirectory ) ).resolves.not.toThrow(); + } ); + + it( 'throws when deleting a missing session id or prefix', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + await expect( deleteAiSession( 'does-not-exist' ) ).rejects.toThrow( + 'AI session not found: does-not-exist' + ); + } ); + + it( 'throws when deleting an ambiguous id prefix', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + await AiSessionRecorder.create( { startedAt: new Date( '2026-03-11T10:00:00.000Z' ) } ); + await AiSessionRecorder.create( { startedAt: new Date( '2026-03-11T11:00:00.000Z' ) } ); + + await expect( deleteAiSession( '' ) ).rejects.toThrow( 'Session id prefix is ambiguous' ); + } ); + + it( 'lists sessions with most recent update first (latest semantics)', async () => { + testRoot = await fs.mkdtemp( path.join( os.tmpdir(), 'studio-ai-sessions-' ) ); + process.env.E2E = '1'; + process.env.E2E_APP_DATA_PATH = testRoot; + + const older = await AiSessionRecorder.create( { + startedAt: new Date( '2026-03-10T10:00:00.000Z' ), + } ); + const latest = await AiSessionRecorder.create( { + startedAt: new Date( '2026-03-11T10:00:00.000Z' ), + } ); + + const sessions = await listAiSessions(); + expect( sessions.map( ( session ) => session.id ) ).toEqual( [ + latest.sessionId, + older.sessionId, + ] ); + } ); + + it( 'replays raw sdk.message events', () => { + const ui = { + activeSite: null, + prepareForReplay: vi.fn(), + finishReplay: vi.fn(), + setReplayTimestamp: vi.fn(), + setActiveSite: vi.fn(), + beginAgentTurn: vi.fn(), + addUserMessage: vi.fn(), + handleMessage: vi.fn(), + setLoaderMessage: vi.fn(), + showAgentQuestion: vi.fn(), + endAgentTurn: vi.fn(), + }; + const sdkAssistantMessage = { + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: 'hello from sdk replay', + }, + ], + }, + }; + const events: AiSessionEvent[] = [ + { + type: 'sdk.message', + timestamp: '2026-03-12T10:00:01.000Z', + message: sdkAssistantMessage as SDKMessage, + }, + ]; + + replaySessionHistory( ui as never, events ); + + expect( ui.prepareForReplay ).toHaveBeenCalledTimes( 1 ); + expect( ui.handleMessage ).toHaveBeenCalledTimes( 1 ); + expect( ui.handleMessage ).toHaveBeenCalledWith( sdkAssistantMessage ); + expect( ui.finishReplay ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'skips ask_user echo lines during replay and starts turns on prompt messages', () => { + const ui = { + activeSite: null, + prepareForReplay: vi.fn(), + finishReplay: vi.fn(), + setReplayTimestamp: vi.fn(), + setActiveSite: vi.fn(), + beginAgentTurn: vi.fn(), + addUserMessage: vi.fn(), + handleMessage: vi.fn(), + setLoaderMessage: vi.fn(), + showAgentQuestion: vi.fn(), + endAgentTurn: vi.fn(), + }; + const events: AiSessionEvent[] = [ + { + type: 'user.message', + timestamp: '2026-03-12T11:00:00.000Z', + text: 'answer from ask_user', + source: 'ask_user', + }, + { + type: 'user.message', + timestamp: '2026-03-12T11:00:01.000Z', + text: 'top-level prompt', + source: 'prompt', + }, + { + type: 'turn.closed', + timestamp: '2026-03-12T11:00:02.000Z', + status: 'success', + }, + ]; + + replaySessionHistory( ui as never, events ); + + expect( ui.beginAgentTurn ).toHaveBeenCalledTimes( 1 ); + expect( ui.addUserMessage ).toHaveBeenCalledTimes( 1 ); + expect( ui.addUserMessage ).toHaveBeenCalledWith( 'top-level prompt' ); + expect( ui.endAgentTurn ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'closes an open replay turn when turn.closed is missing', () => { + const ui = { + activeSite: null, + prepareForReplay: vi.fn(), + finishReplay: vi.fn(), + setReplayTimestamp: vi.fn(), + setActiveSite: vi.fn(), + beginAgentTurn: vi.fn(), + addUserMessage: vi.fn(), + handleMessage: vi.fn(), + setLoaderMessage: vi.fn(), + showAgentQuestion: vi.fn(), + endAgentTurn: vi.fn(), + }; + const events: AiSessionEvent[] = [ + { + type: 'user.message', + timestamp: '2026-03-12T12:00:00.000Z', + text: 'top-level prompt', + source: 'prompt', + }, + { + type: 'sdk.message', + timestamp: '2026-03-12T12:00:01.000Z', + message: { + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: 'partial response', + }, + ], + }, + } as SDKMessage, + }, + ]; + + replaySessionHistory( ui as never, events ); + + expect( ui.beginAgentTurn ).toHaveBeenCalledTimes( 1 ); + expect( ui.endAgentTurn ).toHaveBeenCalledTimes( 1 ); + expect( ui.finishReplay ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'replays remote site metadata from site.selected events', () => { + const ui = { + activeSite: null, + prepareForReplay: vi.fn(), + finishReplay: vi.fn(), + setReplayTimestamp: vi.fn(), + setActiveSite: vi.fn(), + beginAgentTurn: vi.fn(), + addUserMessage: vi.fn(), + handleMessage: vi.fn(), + setLoaderMessage: vi.fn(), + showAgentQuestion: vi.fn(), + endAgentTurn: vi.fn(), + }; + const events: AiSessionEvent[] = [ + { + type: 'site.selected', + timestamp: '2026-03-12T13:00:00.000Z', + siteName: 'my-remote-site', + sitePath: '', + remote: true, + url: 'https://my-remote-site.wordpress.com', + }, + ]; + + replaySessionHistory( ui as never, events ); + + expect( ui.setActiveSite ).toHaveBeenCalledWith( + { + name: 'my-remote-site', + path: '', + running: false, + remote: true, + url: 'https://my-remote-site.wordpress.com', + }, + { announce: true, emitEvent: false } + ); + } ); +} ); diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3004d9a839..43e9cf1b11 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -157,8 +157,38 @@ async function main() { .strict(); if ( __ENABLE_STUDIO_AI__ ) { - const { registerCommand: registerAiCommand } = await import( 'cli/commands/ai' ); - registerAiCommand( studioArgv ); + studioArgv.command( 'ai', __( 'AI-powered WordPress assistant' ), async ( aiYargs ) => { + const { registerCommand: registerAiCommand } = await import( 'cli/commands/ai' ); + registerAiCommand( aiYargs ); + aiYargs.command( 'sessions', __( 'Manage AI sessions' ), async ( sessionsYargs ) => { + const [ + { registerCommand: registerAiSessionsListCommand }, + { registerCommand: registerAiSessionsResumeCommand }, + { registerCommand: registerAiSessionsDeleteCommand }, + ] = await Promise.all( [ + import( 'cli/commands/ai/sessions/list' ), + import( 'cli/commands/ai/sessions/resume' ), + import( 'cli/commands/ai/sessions/delete' ), + ] ); + + sessionsYargs + .option( 'path', { + hidden: true, + } ) + .option( 'session-persistence', { + type: 'boolean', + default: true, + description: __( 'Record this AI chat session to disk' ), + } ); + registerAiSessionsListCommand( sessionsYargs ); + registerAiSessionsResumeCommand( sessionsYargs ); + registerAiSessionsDeleteCommand( sessionsYargs ); + sessionsYargs + .version( false ) + .demandCommand( 1, __( 'You must provide a valid ai sessions command' ) ); + } ); + aiYargs.version( false ); + } ); } if ( __ENABLE_AGENT_SUITE__ ) { const { registerCommand: registerMcpCommand } = await import( 'cli/commands/mcp' );