From aa4372c35bd7e481eab7d4d843a4629874a43092 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 11:29:29 +0100 Subject: [PATCH 01/33] Add sessions commands --- apps/cli/commands/ai.ts | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index c2479a8f3c..7cc39ceac7 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -308,9 +308,41 @@ export const registerCommand = ( yargs: StudioArgv ) => { command: 'ai', describe: __( 'AI-powered WordPress assistant' ), builder: ( yargs ) => { - return yargs.option( 'path', { - hidden: true, - } ); + return yargs + .option( 'path', { + hidden: true, + } ) + .command( { + command: 'sessions', + describe: __( 'Manage AI sessions' ), + builder: ( sessionsYargs ) => { + return sessionsYargs + .command( { + command: 'list', + describe: __( 'List AI sessions' ), + handler: async () => { + // Not implemented + }, + } ) + .command( { + command: 'resume ', + describe: __( 'Resume an AI session' ), + handler: async () => { + // Not implemented + }, + } ) + .command( { + command: 'delete ', + describe: __( 'Delete an AI session' ), + handler: async () => { + // Not implemented + }, + } ) + .version( false ) + .demandCommand( 1, __( 'You must provide a valid ai sessions command' ) ); + }, + handler: async () => {}, + } ); }, handler: async () => { try { From 880e56e7eea23e7d074027d31258733a4280dedf Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 12:46:36 +0100 Subject: [PATCH 02/33] Persist AI sessions as JSONL in date-based appdata subfolders --- apps/cli/ai/ui.ts | 2 +- apps/cli/commands/ai.ts | 203 ++++++++++++++++++++-- apps/cli/lib/ai-sessions.ts | 223 +++++++++++++++++++++++++ apps/cli/lib/tests/ai-sessions.test.ts | 122 ++++++++++++++ 4 files changed, 537 insertions(+), 13 deletions(-) create mode 100644 apps/cli/lib/ai-sessions.ts create mode 100644 apps/cli/lib/tests/ai-sessions.test.ts diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 08e513c0cb..188df91224 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -246,7 +246,7 @@ const toolDisplayNames: Record< string, string > = { TodoWrite: 'Update todo list', }; -function getToolDetail( name: string, input: Record< string, unknown > ): string { +export function getToolDetail( name: string, input: Record< string, unknown > ): string { switch ( name ) { case 'mcp__studio__site_create': return typeof input.name === 'string' ? input.name : ''; diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index 7cc39ceac7..231b1e7793 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -25,6 +25,7 @@ import { runCommand as runLogoutCommand } from 'cli/commands/auth/logout'; import { getAnthropicApiKey, getAuthToken } from 'cli/lib/appdata'; import { Logger, LoggerError, setProgressCallback } from 'cli/logger'; import { StudioArgv } from 'cli/types'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; const logger = new Logger< string >(); @@ -35,6 +36,129 @@ function isPromptAbortError( error: unknown ): boolean { ); } +function getErrorMessage( error: unknown ): string { + if ( error instanceof Error ) { + return error.message; + } + + return String( error ); +} + +function extractAssistantMessageBlocks( message: SDKMessage ): AssistantMessageBlock[] { + if ( message.type !== 'assistant' ) { + return []; + } + + const blocks: AssistantMessageBlock[] = []; + for ( const block of message.message.content ) { + if ( block.type === 'text' && block.text ) { + blocks.push( { + type: 'text', + text: block.text, + } ); + } + + if ( block.type === 'tool_use' && block.name ) { + const detail = + block.input && typeof block.input === 'object' + ? getToolDetail( block.name, block.input as Record< string, unknown > ) + : ''; + blocks.push( { + type: 'tool_use', + name: block.name, + detail: detail || undefined, + } ); + } + } + + return blocks; +} + +function toToolResultText( value: unknown ): string { + if ( Array.isArray( value ) ) { + const lines = value + .map( ( item ) => { + if ( typeof item === 'string' ) { + return item; + } + + if ( item && typeof item === 'object' ) { + const typedItem = item as { type?: unknown; text?: unknown }; + if ( typedItem.type === 'text' && typeof typedItem.text === 'string' ) { + return typedItem.text; + } + + try { + return JSON.stringify( item, null, 2 ); + } catch { + return String( item ); + } + } + + return String( item ); + } ) + .map( ( line ) => line.trim() ) + .filter( ( line ) => line.length > 0 ); + + return lines.join( '\n' ); + } + + if ( typeof value === 'string' ) { + return value.trim(); + } + + if ( value === null || value === undefined ) { + return ''; + } + + try { + return JSON.stringify( value, null, 2 ); + } catch { + return String( value ); + } +} + +function extractToolResult( message: SDKMessage ): { ok: boolean; text: string } | undefined { + if ( message.type !== 'user' ) { + return undefined; + } + + const rawResult = message.tool_use_result; + if ( ! rawResult ) { + return undefined; + } + + if ( typeof rawResult !== 'object' ) { + const text = String( rawResult ).trim(); + return { + ok: true, + text: text || 'Tool completed with no textual output.', + }; + } + + const typedResult = rawResult as { + content?: unknown; + isError?: unknown; + is_error?: unknown; + }; + const isError = typedResult.isError === true || typedResult.is_error === true; + const textFromContent = toToolResultText( typedResult.content ); + + if ( textFromContent ) { + return { + ok: ! isError, + text: textFromContent, + }; + } + + return { + ok: ! isError, + text: isError + ? 'Tool returned an error with no textual output.' + : 'Tool completed with no textual output.', + }; +} + export async function runCommand(): Promise< void > { const ui = new AiChatUI(); let currentProvider: AiProviderId = await resolveInitialAiProvider(); @@ -43,6 +167,31 @@ export async function runCommand(): Promise< void > { ui.start(); ui.showWelcome(); + let sessionRecorder: AiSessionRecorder | undefined; + let didDisableSessionPersistence = false; + try { + sessionRecorder = await AiSessionRecorder.create(); + } catch ( error ) { + didDisableSessionPersistence = true; + ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); + } + + const persist = async ( callback: ( recorder: AiSessionRecorder ) => Promise< void > ) => { + if ( ! sessionRecorder ) { + return; + } + + try { + await callback( sessionRecorder ); + } catch ( error ) { + sessionRecorder = undefined; + if ( ! didDisableSessionPersistence ) { + didDisableSessionPersistence = true; + ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); + } + } + }; + let sessionId: string | undefined; let currentModel: AiModelId = DEFAULT_MODEL; @@ -126,12 +275,20 @@ export async function runCommand(): Promise< void > { }]\n\n${ prompt }`; } + 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 +296,44 @@ 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 assistantBlocks = extractAssistantMessageBlocks( message ); + if ( assistantBlocks.length > 0 ) { + await persist( ( recorder ) => recorder.recordAssistantMessage( assistantBlocks ) ); + } + + const toolResult = extractToolResult( message ); + if ( toolResult ) { + await persist( ( recorder ) => recorder.recordToolResult( toolResult ) ); + } + + const result = ui.handleMessage( message ); + 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 ) }` diff --git a/apps/cli/lib/ai-sessions.ts b/apps/cli/lib/ai-sessions.ts new file mode 100644 index 0000000000..e4de40ac0a --- /dev/null +++ b/apps/cli/lib/ai-sessions.ts @@ -0,0 +1,223 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import { getAppdataDirectory } from 'cli/lib/appdata'; + +export type TurnStatus = 'success' | 'error' | 'max_turns' | 'interrupted'; + +export type AssistantMessageBlock = + | { + type: 'text'; + text: string; + } + | { + type: 'tool_use'; + name: string; + detail?: string; + }; + +export type AiSessionEvent = + | { + type: 'session.started'; + timestamp: string; + version: 1; + sessionId: string; + } + | { + type: 'session.linked'; + timestamp: string; + agentSessionId: string; + } + | { + type: 'site.selected'; + timestamp: string; + siteName: string; + sitePath: string; + } + | { + type: 'user.message'; + timestamp: string; + text: string; + source: 'prompt' | 'ask_user'; + sitePath?: string; + } + | { + type: 'assistant.message'; + timestamp: string; + blocks: AssistantMessageBlock[]; + } + | { + type: 'tool.result'; + timestamp: string; + ok: boolean; + text: string; + } + | { + type: 'agent.question'; + timestamp: string; + question: string; + options: Array< { + label: string; + description: string; + } >; + } + | { + type: 'turn.closed'; + timestamp: string; + status: TurnStatus; + }; + +export function getAiSessionsRootDirectory(): string { + return path.join( getAppdataDirectory(), 'sessions' ); +} + +function formatDatePart( value: number ): string { + return String( value ).padStart( 2, '0' ); +} + +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 ); +} + +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 ) { + this.sessionId = sessionId; + this.filePath = filePath; + } + + static async create( options: { startedAt?: Date } = {} ): Promise< AiSessionRecorder > { + const startedAt = options.startedAt ?? new Date(); + const sessionId = crypto.randomUUID(); + const directory = getAiSessionsDirectoryForDate( startedAt ); + const filePath = path.join( directory, `${ sessionId }.jsonl` ); + + 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; + } + + 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 recordSiteSelected( site: { name: string; path: string } ): Promise< void > { + await this.appendEvent( { + type: 'site.selected', + timestamp: toIsoTimestamp(), + siteName: site.name, + sitePath: site.path, + } ); + } + + 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 recordAssistantMessage( blocks: AssistantMessageBlock[] ): Promise< void > { + if ( blocks.length === 0 ) { + return; + } + + await this.appendEvent( { + type: 'assistant.message', + timestamp: toIsoTimestamp(), + blocks, + } ); + } + + async recordToolResult( options: { ok: boolean; text: string } ): Promise< void > { + await this.appendEvent( { + type: 'tool.result', + timestamp: toIsoTimestamp(), + ok: options.ok, + text: options.text, + } ); + } + + 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', + } ); + } +} + +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; +} diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts new file mode 100644 index 0000000000..26f963954c --- /dev/null +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -0,0 +1,122 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + AiSessionRecorder, + getAiSessionsDirectoryForDate, + readAiSessionEventsFromFile, +} from 'cli/lib/ai-sessions'; + +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.recordAssistantMessage( [ + { type: 'text', text: 'Sure, I can help with that.' }, + { type: 'tool_use', name: 'Read' }, + ] ); + await recorder.recordToolResult( { + ok: true, + text: 'File read successfully', + } ); + 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 + ); + 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 === 'assistant.message' ) ).toMatchObject( { + type: 'assistant.message', + blocks: [ + { type: 'text', text: 'Sure, I can help with that.' }, + { type: 'tool_use', name: 'Read' }, + ], + } ); + 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', + }, + ] ); + } ); +} ); From b2f5ef4b86ca1b4a67106aa92bd0c9a8a1b73018 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 14:35:36 +0100 Subject: [PATCH 03/33] Add sessions resuming capability --- apps/cli/ai/ui.ts | 8 +- apps/cli/commands/ai.ts | 29 ++++- apps/cli/lib/ai-sessions.ts | 154 ++++++++++++++++++++++++- apps/cli/lib/tests/ai-sessions.test.ts | 58 ++++++++++ 4 files changed, 242 insertions(+), 7 deletions(-) diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 188df91224..5813fe87d4 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -291,7 +291,7 @@ export function getToolDetail( name: string, input: Record< string, unknown > ): case 'Glob': return typeof input.pattern === 'string' ? input.pattern : ''; default: - return ''; + return typeof input.detail === 'string' ? input.detail : ''; } } @@ -542,6 +542,10 @@ export class AiChatUI { return this._activeSite; } + set onSiteSelected( fn: ( ( site: SiteInfo ) => void ) | null ) { + this.siteSelectedCallback = fn; + } + constructor() { const terminal = new ProcessTerminal(); this.tui = new TUI( terminal ); @@ -1790,7 +1794,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; diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index 231b1e7793..084fabface 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -169,8 +169,18 @@ export async function runCommand(): Promise< void > { let sessionRecorder: AiSessionRecorder | undefined; let didDisableSessionPersistence = false; + let sessionId: string | undefined = options.resumeSession?.summary.agentSessionId; + try { - sessionRecorder = await AiSessionRecorder.create(); + 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 ) }` ); @@ -192,7 +202,6 @@ export async function runCommand(): Promise< void > { } }; - let sessionId: string | undefined; let currentModel: AiModelId = DEFAULT_MODEL; async function prepareProviderSelection( @@ -506,8 +515,20 @@ export const registerCommand = ( yargs: StudioArgv ) => { .command( { command: 'resume ', describe: __( 'Resume an AI session' ), - handler: async () => { - // Not implemented + handler: async ( argv ) => { + try { + await runResumeSessionCommand( argv.id as string ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( + __( 'Failed to resume AI session' ), + error + ); + logger.reportError( loggerError ); + } + } }, } ) .command( { diff --git a/apps/cli/lib/ai-sessions.ts b/apps/cli/lib/ai-sessions.ts index e4de40ac0a..61ab86f8a7 100644 --- a/apps/cli/lib/ai-sessions.ts +++ b/apps/cli/lib/ai-sessions.ts @@ -67,6 +67,20 @@ export type AiSessionEvent = status: TurnStatus; }; +export interface AiSessionSummary { + id: string; + filePath: string; + createdAt: string; + updatedAt: string; + agentSessionId?: string; + linkedAgentSessionIds: string[]; +} + +export interface LoadedAiSession { + summary: AiSessionSummary; + events: AiSessionEvent[]; +} + export function getAiSessionsRootDirectory(): string { return path.join( getAppdataDirectory(), 'sessions' ); } @@ -92,9 +106,10 @@ export class AiSessionRecorder { private linkedAgentSessionIds = new Set< string >(); - private constructor( sessionId: string, filePath: 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 > { @@ -116,6 +131,19 @@ export class AiSessionRecorder { 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; @@ -221,3 +249,127 @@ export async function readAiSessionEventsFromFile( filePath: string ): Promise< return events; } + +function getSessionIdFromPath( filePath: string ): string { + return path.basename( filePath, '.jsonl' ); +} + +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 readAiSessionSummaryFromFile( + filePath: string +): Promise< AiSessionSummary | undefined > { + const events = await readAiSessionEventsFromFile( filePath ); + if ( events.length === 0 ) { + return undefined; + } + + const linkedAgentSessionIds: string[] = []; + let createdAt: string | undefined; + let updatedAt: string | undefined; + let sessionId = getSessionIdFromPath( filePath ); + + for ( const event of events ) { + 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 ); + } + } + + 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, + }; +} + +export async function listAiSessions(): Promise< AiSessionSummary[] > { + const sessionFiles = await listSessionFilesRecursively( getAiSessionsRootDirectory() ); + const results = await Promise.allSettled( + sessionFiles.map( ( filePath ) => readAiSessionSummaryFromFile( filePath ) ) + ); + + 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 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 ? ', …' : '' + }` + ); + } + + const summary = candidates[ 0 ]; + const events = await readAiSessionEventsFromFile( summary.filePath ); + return { summary, events }; +} diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts index 26f963954c..ffae3eca54 100644 --- a/apps/cli/lib/tests/ai-sessions.test.ts +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { AiSessionRecorder, getAiSessionsDirectoryForDate, + loadAiSession, + listAiSessions, readAiSessionEventsFromFile, } from 'cli/lib/ai-sessions'; @@ -119,4 +121,60 @@ describe( 'ai-sessions', () => { }, ] ); } ); + + 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', + } ); + } ); } ); From 91feb84b4df622430aa409e60f7becc91c52263c Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 15:18:53 +0100 Subject: [PATCH 04/33] Implement sessions listing command --- apps/cli/commands/ai.ts | 171 ++++++++++++++++++++++++- apps/cli/lib/ai-sessions.ts | 29 +++++ apps/cli/lib/tests/ai-sessions.test.ts | 27 ++++ 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index 084fabface..50643698fe 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -44,6 +44,155 @@ function getErrorMessage( error: unknown ): string { return String( error ); } +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 ( text.length <= maxLength ) { + return text; + } + + if ( maxLength <= 1 ) { + return '…'; + } + + return text.slice( 0, maxLength - 1 ) + '…'; +} + +function visibleWidth( text: string ): number { + return Array.from( text ).length; +} + +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 ) }`; +} + +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 runListSessionsCommand( format: 'compact' | 'json' ): 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 ); +} + function extractAssistantMessageBlocks( message: SDKMessage ): AssistantMessageBlock[] { if ( message.type !== 'assistant' ) { return []; @@ -508,8 +657,26 @@ export const registerCommand = ( yargs: StudioArgv ) => { .command( { command: 'list', describe: __( 'List AI sessions' ), - handler: async () => { - // Not implemented + 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 runListSessionsCommand( argv.format as 'compact' | 'json' ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( + new LoggerError( __( 'Failed to list AI sessions' ), error ) + ); + } + } }, } ) .command( { diff --git a/apps/cli/lib/ai-sessions.ts b/apps/cli/lib/ai-sessions.ts index 61ab86f8a7..f3eb858f54 100644 --- a/apps/cli/lib/ai-sessions.ts +++ b/apps/cli/lib/ai-sessions.ts @@ -74,6 +74,10 @@ export interface AiSessionSummary { updatedAt: string; agentSessionId?: string; linkedAgentSessionIds: string[]; + firstPrompt?: string; + selectedSiteName?: string; + endReason?: 'error' | 'stopped'; + eventCount: number; } export interface LoadedAiSession { @@ -297,8 +301,13 @@ async function readAiSessionSummaryFromFile( let createdAt: string | undefined; let updatedAt: string | undefined; let sessionId = getSessionIdFromPath( 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' ) { @@ -314,6 +323,22 @@ async function readAiSessionSummaryFromFile( ) { 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 ); @@ -326,6 +351,10 @@ async function readAiSessionSummaryFromFile( updatedAt: updatedAt ?? createdAt ?? fallbackTimestamp, agentSessionId: linkedAgentSessionIds[ linkedAgentSessionIds.length - 1 ], linkedAgentSessionIds, + firstPrompt, + selectedSiteName, + endReason, + eventCount, }; } diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts index ffae3eca54..b2798e4d41 100644 --- a/apps/cli/lib/tests/ai-sessions.test.ts +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -177,4 +177,31 @@ describe( 'ai-sessions', () => { 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 ); + } ); } ); From 7a0fedf768643e2e942675d46f591ad21a763e1d Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 15:56:29 +0100 Subject: [PATCH 05/33] Save tool progress to sessions --- apps/cli/ai/ui.ts | 20 +++++++++++--------- apps/cli/commands/ai.ts | 18 +++++++----------- apps/cli/lib/ai-sessions.ts | 17 +++++++++++++++++ apps/cli/lib/tests/ai-sessions.test.ts | 5 +++++ 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 5813fe87d4..6c1830a3dd 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -247,9 +247,11 @@ const toolDisplayNames: Record< string, string > = { }; export function getToolDetail( name: string, input: Record< string, unknown > ): string { + const fallbackDetail = typeof input.detail === 'string' ? input.detail : ''; + switch ( name ) { case 'mcp__studio__site_create': - return typeof input.name === 'string' ? input.name : ''; + return typeof input.name === 'string' ? input.name : fallbackDetail; case 'mcp__studio__site_info': case 'mcp__studio__site_start': case 'mcp__studio__site_stop': @@ -261,14 +263,14 @@ export function getToolDetail( name: string, input: Record< string, unknown > ): case 'mcp__studio__preview_delete': return typeof input.host === 'string' ? input.host : ''; case 'mcp__studio__wp_cli': - return typeof input.command === 'string' ? `wp ${ input.command }` : ''; + return typeof input.command === 'string' ? `wp ${ input.command }` : fallbackDetail; case 'mcp__studio__validate_blocks': if ( typeof input.filePath === 'string' ) { return input.filePath.split( '/' ).slice( -2 ).join( '/' ); } - return 'inline content'; + return fallbackDetail || 'inline content'; case 'mcp__studio__take_screenshot': - return typeof input.url === 'string' ? input.url : ''; + return typeof input.url === 'string' ? input.url : fallbackDetail; case 'Read': case 'Write': case 'Edit': { @@ -277,21 +279,21 @@ export function getToolDetail( name: string, input: Record< string, unknown > ): const parts = filePath.split( '/' ); return parts.slice( -2 ).join( '/' ); } - return ''; + return fallbackDetail; } case 'Bash': return typeof input.command === 'string' ? input.command.length > 60 ? input.command.slice( 0, 57 ) + '…' : input.command - : ''; + : fallbackDetail; case 'Skill': - return typeof input.skill === 'string' ? input.skill : ''; + return typeof input.skill === 'string' ? input.skill : fallbackDetail; case 'Grep': case 'Glob': - return typeof input.pattern === 'string' ? input.pattern : ''; + return typeof input.pattern === 'string' ? input.pattern : fallbackDetail; default: - return typeof input.detail === 'string' ? input.detail : ''; + return fallbackDetail; } } diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index 50643698fe..705e71a28b 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -281,7 +281,7 @@ function extractToolResult( message: SDKMessage ): { ok: boolean; text: string } const text = String( rawResult ).trim(); return { ok: true, - text: text || 'Tool completed with no textual output.', + text, }; } @@ -293,18 +293,9 @@ function extractToolResult( message: SDKMessage ): { ok: boolean; text: string } const isError = typedResult.isError === true || typedResult.is_error === true; const textFromContent = toToolResultText( typedResult.content ); - if ( textFromContent ) { - return { - ok: ! isError, - text: textFromContent, - }; - } - return { ok: ! isError, - text: isError - ? 'Tool returned an error with no textual output.' - : 'Tool completed with no textual output.', + text: textFromContent, }; } @@ -351,6 +342,11 @@ export async function runCommand(): Promise< void > { } }; + setProgressCallback( ( message ) => { + ui.setLoaderMessage( message ); + void persist( ( recorder ) => recorder.recordToolProgress( message ) ); + } ); + let currentModel: AiModelId = DEFAULT_MODEL; async function prepareProviderSelection( diff --git a/apps/cli/lib/ai-sessions.ts b/apps/cli/lib/ai-sessions.ts index f3eb858f54..0d20bc3365 100644 --- a/apps/cli/lib/ai-sessions.ts +++ b/apps/cli/lib/ai-sessions.ts @@ -52,6 +52,11 @@ export type AiSessionEvent = ok: boolean; text: string; } + | { + type: 'tool.progress'; + timestamp: string; + message: string; + } | { type: 'agent.question'; timestamp: string; @@ -205,6 +210,18 @@ export class AiSessionRecorder { } ); } + async recordToolProgress( message: string ): Promise< void > { + if ( ! message.trim() ) { + return; + } + + await this.appendEvent( { + type: 'tool.progress', + timestamp: toIsoTimestamp(), + message, + } ); + } + async recordAgentQuestion( options: { question: string; options: Array< { diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts index b2798e4d41..c0e67ee510 100644 --- a/apps/cli/lib/tests/ai-sessions.test.ts +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -48,6 +48,7 @@ describe( 'ai-sessions', () => { ok: true, text: 'File read successfully', } ); + await recorder.recordToolProgress( 'Starting process daemon…' ); await recorder.recordAgentQuestion( { question: 'Choose a plugin slug', options: [ @@ -86,6 +87,10 @@ describe( 'ai-sessions', () => { { type: 'tool_use', name: 'Read' }, ], } ); + 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', From def97f90802f544c6afe29a928a51f5011ee1764 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 15:59:27 +0100 Subject: [PATCH 06/33] Add support for "latest" argument in sessions resume --- apps/cli/commands/ai.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index 705e71a28b..88ba7e5599 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -677,7 +677,13 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ) .command( { command: 'resume ', - describe: __( 'Resume an AI session' ), + describe: __( 'Resume an AI session (id or "latest")' ), + builder: ( resumeYargs ) => { + return resumeYargs.positional( 'id', { + type: 'string', + describe: __( 'Session id (or "latest")' ), + } ); + }, handler: async ( argv ) => { try { await runResumeSessionCommand( argv.id as string ); From 3b5776e74208310db7d0916a548d3c415bd7259c Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 16:06:26 +0100 Subject: [PATCH 07/33] Implement sessions delete command --- apps/cli/commands/ai.ts | 22 +++++++++-- apps/cli/lib/ai-sessions.ts | 53 ++++++++++++++++++++++++++ apps/cli/lib/tests/ai-sessions.test.ts | 30 +++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index 88ba7e5599..cc5c24c147 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -702,9 +702,25 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ) .command( { command: 'delete ', - describe: __( 'Delete an AI session' ), - handler: async () => { - // Not implemented + describe: __( 'Delete an AI session (id, prefix, or "latest")' ), + builder: ( deleteYargs ) => { + return deleteYargs.positional( 'id', { + type: 'string', + describe: __( 'Session id, id prefix, or "latest"' ), + } ); + }, + handler: async ( argv ) => { + try { + await runDeleteSessionCommand( argv.id as string ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( + new LoggerError( __( 'Failed to delete AI session' ), error ) + ); + } + } }, } ) .version( false ) diff --git a/apps/cli/lib/ai-sessions.ts b/apps/cli/lib/ai-sessions.ts index 0d20bc3365..1467c15688 100644 --- a/apps/cli/lib/ai-sessions.ts +++ b/apps/cli/lib/ai-sessions.ts @@ -419,3 +419,56 @@ export async function loadAiSession( sessionIdOrPrefix: string ): Promise< Loade const events = await readAiSessionEventsFromFile( summary.filePath ); return { summary, events }; } + +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 deleteAiSession( 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 ? ', …' : '' + }` + ); + } + + const sessionToDelete = candidates[ 0 ]; + await fs.rm( sessionToDelete.filePath, { force: false } ); + await pruneEmptySessionDirectories( path.dirname( sessionToDelete.filePath ) ); + + return sessionToDelete; +} diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts index c0e67ee510..6a199d52fa 100644 --- a/apps/cli/lib/tests/ai-sessions.test.ts +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -4,7 +4,9 @@ import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; import { AiSessionRecorder, + deleteAiSession, getAiSessionsDirectoryForDate, + getAiSessionsRootDirectory, loadAiSession, listAiSessions, readAiSessionEventsFromFile, @@ -209,4 +211,32 @@ describe( 'ai-sessions', () => { } ); 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(); + } ); } ); From d358405c4b881861d16bcca109fee055497fbe84 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 16:18:04 +0100 Subject: [PATCH 08/33] Add timestamp to sessions filename --- apps/cli/lib/ai-sessions.ts | 16 ++++++++++++++-- apps/cli/lib/tests/ai-sessions.test.ts | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/cli/lib/ai-sessions.ts b/apps/cli/lib/ai-sessions.ts index 1467c15688..8dbde331d5 100644 --- a/apps/cli/lib/ai-sessions.ts +++ b/apps/cli/lib/ai-sessions.ts @@ -98,6 +98,13 @@ function formatDatePart( value: number ): string { return String( value ).padStart( 2, '0' ); } +function toSortableTimestampPrefix( date: Date ): string { + return date + .toISOString() + .replace( /:/g, '-' ) + .replace( /\.\d{3}Z$/, '' ); +} + export function getAiSessionsDirectoryForDate( date: Date ): string { const year = String( date.getFullYear() ); const month = formatDatePart( date.getMonth() + 1 ); @@ -125,7 +132,8 @@ export class AiSessionRecorder { const startedAt = options.startedAt ?? new Date(); const sessionId = crypto.randomUUID(); const directory = getAiSessionsDirectoryForDate( startedAt ); - const filePath = path.join( directory, `${ sessionId }.jsonl` ); + const fileName = `${ toSortableTimestampPrefix( startedAt ) }-${ sessionId }.jsonl`; + const filePath = path.join( directory, fileName ); await fs.mkdir( directory, { recursive: true } ); @@ -272,7 +280,11 @@ export async function readAiSessionEventsFromFile( filePath: string ): Promise< } function getSessionIdFromPath( filePath: string ): string { - return path.basename( filePath, '.jsonl' ); + const fileName = path.basename( filePath, '.jsonl' ); + const uuidMatch = fileName.match( + /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i + ); + return uuidMatch?.[ 1 ] ?? fileName; } async function listSessionFilesRecursively( directory: string ): Promise< string[] > { diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts index 6a199d52fa..f78c266f61 100644 --- a/apps/cli/lib/tests/ai-sessions.test.ts +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -71,6 +71,8 @@ describe( 'ai-sessions', () => { 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, From 63026e7b553491f8319e950380f18ae33c417b8b Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 16:23:32 +0100 Subject: [PATCH 09/33] Add sessions browser for agument-less delete and resume commands --- apps/cli/commands/ai.ts | 91 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 7 deletions(-) diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index cc5c24c147..324512b8d5 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -193,6 +193,79 @@ async function runListSessionsCommand( format: 'compact' | 'json' ): Promise< vo displaySessionsCompact( sessions ); } +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 ); + } + } +} + +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 ); +} + function extractAssistantMessageBlocks( message: SDKMessage ): AssistantMessageBlock[] { if ( message.type !== 'assistant' ) { return []; @@ -676,17 +749,19 @@ export const registerCommand = ( yargs: StudioArgv ) => { }, } ) .command( { - command: 'resume ', - describe: __( 'Resume an AI session (id or "latest")' ), + command: 'resume [id]', + describe: __( 'Resume an AI session (id, prefix, "latest", or picker)' ), builder: ( resumeYargs ) => { return resumeYargs.positional( 'id', { type: 'string', - describe: __( 'Session id (or "latest")' ), + describe: __( 'Session id, id prefix, or "latest"' ), } ); }, handler: async ( argv ) => { try { - await runResumeSessionCommand( argv.id as string ); + await runResumeSessionCommand( + typeof argv.id === 'string' ? argv.id : undefined + ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); @@ -701,8 +776,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { }, } ) .command( { - command: 'delete ', - describe: __( 'Delete an AI session (id, prefix, or "latest")' ), + command: 'delete [id]', + describe: __( 'Delete an AI session (id, prefix, "latest", or picker)' ), builder: ( deleteYargs ) => { return deleteYargs.positional( 'id', { type: 'string', @@ -711,7 +786,9 @@ export const registerCommand = ( yargs: StudioArgv ) => { }, handler: async ( argv ) => { try { - await runDeleteSessionCommand( argv.id as string ); + await runDeleteSessionCommand( + typeof argv.id === 'string' ? argv.id : undefined + ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); From 2b2fc5f2f90b7b59b1d32a069383b8572d386416 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 19:21:09 +0100 Subject: [PATCH 10/33] Add additional ai and ai-sessions tests cases --- apps/cli/commands/tests/ai.test.ts | 236 +++++++++++++++++++++++++ apps/cli/lib/tests/ai-sessions.test.ts | 74 ++++++++ 2 files changed, 310 insertions(+) create mode 100644 apps/cli/commands/tests/ai.test.ts diff --git a/apps/cli/commands/tests/ai.test.ts b/apps/cli/commands/tests/ai.test.ts new file mode 100644 index 0000000000..d707e5984a --- /dev/null +++ b/apps/cli/commands/tests/ai.test.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import yargs from 'yargs/yargs'; +import { registerCommand } from 'cli/commands/ai'; +import { + AiSessionRecorder, + deleteAiSession, + listAiSessions, + loadAiSession, +} from 'cli/lib/ai-sessions'; +import { getAnthropicApiKey } from 'cli/lib/appdata'; +import { StudioArgv } from 'cli/types'; + +const { reportErrorMock } = vi.hoisted( () => ( { + reportErrorMock: vi.fn(), +} ) ); + +vi.mock( 'cli/lib/appdata', () => ( { + getAnthropicApiKey: vi.fn(), + saveAnthropicApiKey: vi.fn(), +} ) ); + +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', () => { + const emptyQuery = { + interrupt: vi.fn().mockResolvedValue( undefined ), + [ Symbol.asyncIterator ]() { + return { + next: async () => ( { + done: true as const, + value: undefined, + } ), + }; + }, + }; + + return { + AI_MODELS: { + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-opus-4-6': 'Opus 4.6', + }, + DEFAULT_MODEL: 'claude-sonnet-4-6', + 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() {} + prepareForReplay() {} + finishReplay() {} + beginAgentTurn() {} + endAgentTurn() {} + setLoaderMessage() {} + setActiveSite( site: { name: string; path: string; running: boolean } ) { + this.activeSite = site; + } + addUserMessage() {} + handleMessage() { + return undefined; + } + showAgentQuestion() {} + async askUser() { + return {}; + } + async waitForInput() { + return '/exit'; + } + }, + getToolDetail: ( _name: string, input: Record< string, unknown > ) => + typeof input.detail === 'string' ? input.detail : '', +} ) ); + +vi.mock( 'cli/lib/ai-sessions', () => { + class MockAiSessionRecorder { + static create = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); + static open = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); + async recordToolProgress() {} + async recordSiteSelected() {} + async recordUserMessage() {} + async recordAssistantMessage() {} + async recordToolResult() {} + async recordAgentQuestion() {} + async recordTurnClosed() {} + async recordAgentSessionId() {} + } + + return { + AiSessionRecorder: MockAiSessionRecorder, + listAiSessions: vi.fn(), + loadAiSession: vi.fn(), + deleteAiSession: vi.fn(), + }; +} ); + +describe( 'CLI: studio ai sessions command', () => { + beforeEach( () => { + vi.clearAllMocks(); + vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'test-api-key' ); + vi.spyOn( process, 'exit' ).mockImplementation( () => undefined as never ); + } ); + + function buildParser(): StudioArgv { + const parser = yargs( [] ).scriptName( 'studio' ).strict().exitProcess( false ) as StudioArgv; + registerCommand( parser ); + return parser; + } + + 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 ).toHaveBeenCalledWith( + expect.objectContaining( { + sessionId: 'session-latest', + filePath: '/tmp/session-latest.jsonl', + } ) + ); + 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( '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(); + } ); +} ); diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts index f78c266f61..78f0955809 100644 --- a/apps/cli/lib/tests/ai-sessions.test.ts +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -241,4 +241,78 @@ describe( 'ai-sessions', () => { 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, + ] ); + } ); } ); From 60b9c6242be68afb26abe7a464a233636b281577 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 11 Mar 2026 20:02:30 +0100 Subject: [PATCH 11/33] Add a --no-session-persistence flag --- apps/cli/commands/ai.ts | 50 ++++++++++++++++++++---------- apps/cli/commands/tests/ai.test.ts | 49 +++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index 324512b8d5..703adff3c1 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -381,22 +381,26 @@ export async function runCommand(): Promise< void > { ui.showWelcome(); let sessionRecorder: AiSessionRecorder | undefined; - let didDisableSessionPersistence = false; + let didDisableSessionPersistence = options.noSessionPersistence === true; let sessionId: string | undefined = options.resumeSession?.summary.agentSessionId; - 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(); + if ( options.noSessionPersistence ) { + ui.showInfo( 'Session persistence disabled (--no-session-persistence).' ); + } else { + 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 ) }` ); } - } catch ( error ) { - didDisableSessionPersistence = true; - ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); } const persist = async ( callback: ( recorder: AiSessionRecorder ) => Promise< void > ) => { @@ -718,6 +722,11 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'path', { hidden: true, } ) + .option( 'session-persistence', { + type: 'boolean', + default: true, + description: __( 'Record this AI chat session to disk' ), + } ) .command( { command: 'sessions', describe: __( 'Manage AI sessions' ), @@ -759,8 +768,13 @@ export const registerCommand = ( yargs: StudioArgv ) => { }, handler: async ( argv ) => { try { + const noSessionPersistence = + ( argv as { sessionPersistence?: boolean } ).sessionPersistence === false; await runResumeSessionCommand( - typeof argv.id === 'string' ? argv.id : undefined + typeof argv.id === 'string' ? argv.id : undefined, + { + noSessionPersistence, + } ); } catch ( error ) { if ( error instanceof LoggerError ) { @@ -806,9 +820,13 @@ export const registerCommand = ( yargs: StudioArgv ) => { handler: async () => {}, } ); }, - 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/tests/ai.test.ts b/apps/cli/commands/tests/ai.test.ts index d707e5984a..43899f9684 100644 --- a/apps/cli/commands/tests/ai.test.ts +++ b/apps/cli/commands/tests/ai.test.ts @@ -139,6 +139,19 @@ describe( 'CLI: studio ai sessions command', () => { return parser; } + it( 'records sessions by default when running studio ai', async () => { + await buildParser().parseAsync( [ 'ai' ] ); + + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).create ).toHaveBeenCalledTimes( 1 ); + } ); + + 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( [ { @@ -216,6 +229,42 @@ describe( 'CLI: studio ai sessions command', () => { 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( [] ); From be02222c5f035b17c5c15921d01b20dd218b92ff Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 12 Mar 2026 19:15:07 +0100 Subject: [PATCH 12/33] Move ai command to ai subfolder --- apps/cli/commands/{ai.ts => ai/index.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/cli/commands/{ai.ts => ai/index.ts} (100%) diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai/index.ts similarity index 100% rename from apps/cli/commands/ai.ts rename to apps/cli/commands/ai/index.ts From 425e67609d5b30ac8c6d44872bbd8868e2cb8baf Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 12 Mar 2026 19:29:08 +0100 Subject: [PATCH 13/33] Separate ai and ai sessions commands registration into separate files --- apps/cli/commands/ai/index.ts | 316 +----------------------- apps/cli/commands/ai/sessions.ts | 372 +++++++++++++++++++++++++++++ apps/cli/commands/tests/ai.test.ts | 23 +- 3 files changed, 394 insertions(+), 317 deletions(-) create mode 100644 apps/cli/commands/ai/sessions.ts diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index 703adff3c1..8af9647ee6 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -44,228 +44,6 @@ function getErrorMessage( error: unknown ): string { return String( error ); } -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 ( text.length <= maxLength ) { - return text; - } - - if ( maxLength <= 1 ) { - return '…'; - } - - return text.slice( 0, maxLength - 1 ) + '…'; -} - -function visibleWidth( text: string ): number { - return Array.from( text ).length; -} - -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 ) }`; -} - -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 runListSessionsCommand( format: 'compact' | 'json' ): 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 ); -} - -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 ); - } - } -} - -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 ); -} - function extractAssistantMessageBlocks( message: SDKMessage ): AssistantMessageBlock[] { if ( message.type !== 'assistant' ) { return []; @@ -715,7 +493,7 @@ 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 @@ -726,98 +504,6 @@ export const registerCommand = ( yargs: StudioArgv ) => { type: 'boolean', default: true, description: __( 'Record this AI chat session to disk' ), - } ) - .command( { - command: 'sessions', - describe: __( 'Manage AI sessions' ), - builder: ( sessionsYargs ) => { - return sessionsYargs - .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 runListSessionsCommand( argv.format as 'compact' | 'json' ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - logger.reportError( - new LoggerError( __( 'Failed to list AI sessions' ), error ) - ); - } - } - }, - } ) - .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 runResumeSessionCommand( - 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 ); - } - } - }, - } ) - .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 runDeleteSessionCommand( - 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 ) - ); - } - } - }, - } ) - .version( false ) - .demandCommand( 1, __( 'You must provide a valid ai sessions command' ) ); - }, - handler: async () => {}, } ); }, handler: async ( argv ) => { diff --git a/apps/cli/commands/ai/sessions.ts b/apps/cli/commands/ai/sessions.ts new file mode 100644 index 0000000000..f1215bca9d --- /dev/null +++ b/apps/cli/commands/ai/sessions.ts @@ -0,0 +1,372 @@ +import { select } from '@inquirer/prompts'; +import { __ } from '@wordpress/i18n'; +import chalk from 'chalk'; +import { runCommand as runAiCommand } from 'cli/commands/ai'; +import { + deleteAiSession, + listAiSessions, + loadAiSession, + type AiSessionSummary, +} from 'cli/lib/ai-sessions'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const logger = new Logger< string >(); + +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 ( text.length <= maxLength ) { + return text; + } + + if ( maxLength <= 1 ) { + return '…'; + } + + return text.slice( 0, maxLength - 1 ) + '…'; +} + +function visibleWidth( text: string ): number { + return Array.from( text ).length; +} + +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 ) }`; +} + +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 runListSessionsCommand( format: 'compact' | 'json' ): 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 ); +} + +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 ); + } + } +} + +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 ); +} + +async function runResumeSessionCommand( + 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, + } ); +} + +async function runDeleteSessionCommand( 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: '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 runListSessionsCommand( argv.format as 'compact' | 'json' ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + logger.reportError( new LoggerError( __( 'Failed to list AI sessions' ), error ) ); + } + } + }, + } ) + .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 runResumeSessionCommand( 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 ); + } + } + }, + } ) + .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 runDeleteSessionCommand( 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/tests/ai.test.ts b/apps/cli/commands/tests/ai.test.ts index 43899f9684..bd30854d3b 100644 --- a/apps/cli/commands/tests/ai.test.ts +++ b/apps/cli/commands/tests/ai.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import yargs from 'yargs/yargs'; -import { registerCommand } from 'cli/commands/ai'; +import { registerCommand as registerAiCommand } from 'cli/commands/ai'; +import { registerCommand as registerAiSessionsCommand } from 'cli/commands/ai/sessions'; import { AiSessionRecorder, deleteAiSession, @@ -135,7 +136,25 @@ describe( 'CLI: studio ai sessions command', () => { function buildParser(): StudioArgv { const parser = yargs( [] ).scriptName( 'studio' ).strict().exitProcess( false ) as StudioArgv; - registerCommand( parser ); + 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', + } ); + registerAiSessionsCommand( sessionsYargs as StudioArgv ); + sessionsYargs + .version( false ) + .demandCommand( 1, 'You must provide a valid ai sessions command' ); + } ); + aiYargs.version( false ); + } ); return parser; } From 8aeb94619b04214af838b797bc5546492db835da Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 12 Mar 2026 19:33:18 +0100 Subject: [PATCH 14/33] Move sessions from lib to ai folder --- apps/cli/{lib/ai-sessions.ts => ai/sessions.ts} | 0 apps/cli/commands/ai/sessions.ts | 4 ++-- apps/cli/commands/tests/ai.test.ts | 9 ++------- apps/cli/lib/tests/ai-sessions.test.ts | 2 +- 4 files changed, 5 insertions(+), 10 deletions(-) rename apps/cli/{lib/ai-sessions.ts => ai/sessions.ts} (100%) diff --git a/apps/cli/lib/ai-sessions.ts b/apps/cli/ai/sessions.ts similarity index 100% rename from apps/cli/lib/ai-sessions.ts rename to apps/cli/ai/sessions.ts diff --git a/apps/cli/commands/ai/sessions.ts b/apps/cli/commands/ai/sessions.ts index f1215bca9d..19507a7963 100644 --- a/apps/cli/commands/ai/sessions.ts +++ b/apps/cli/commands/ai/sessions.ts @@ -1,13 +1,13 @@ import { select } from '@inquirer/prompts'; import { __ } from '@wordpress/i18n'; import chalk from 'chalk'; -import { runCommand as runAiCommand } from 'cli/commands/ai'; import { deleteAiSession, listAiSessions, loadAiSession, type AiSessionSummary, -} from 'cli/lib/ai-sessions'; +} from 'cli/ai/sessions'; +import { runCommand as runAiCommand } from 'cli/commands/ai'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; diff --git a/apps/cli/commands/tests/ai.test.ts b/apps/cli/commands/tests/ai.test.ts index bd30854d3b..c918d622a3 100644 --- a/apps/cli/commands/tests/ai.test.ts +++ b/apps/cli/commands/tests/ai.test.ts @@ -1,13 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import yargs from 'yargs/yargs'; +import { AiSessionRecorder, deleteAiSession, listAiSessions, loadAiSession } from 'cli/ai/sessions'; import { registerCommand as registerAiCommand } from 'cli/commands/ai'; import { registerCommand as registerAiSessionsCommand } from 'cli/commands/ai/sessions'; -import { - AiSessionRecorder, - deleteAiSession, - listAiSessions, - loadAiSession, -} from 'cli/lib/ai-sessions'; import { getAnthropicApiKey } from 'cli/lib/appdata'; import { StudioArgv } from 'cli/types'; @@ -105,7 +100,7 @@ vi.mock( 'cli/ai/ui', () => ( { typeof input.detail === 'string' ? input.detail : '', } ) ); -vi.mock( 'cli/lib/ai-sessions', () => { +vi.mock( 'cli/ai/sessions', () => { class MockAiSessionRecorder { static create = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); static open = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/lib/tests/ai-sessions.test.ts index 78f0955809..6468e5d20f 100644 --- a/apps/cli/lib/tests/ai-sessions.test.ts +++ b/apps/cli/lib/tests/ai-sessions.test.ts @@ -10,7 +10,7 @@ import { loadAiSession, listAiSessions, readAiSessionEventsFromFile, -} from 'cli/lib/ai-sessions'; +} from 'cli/ai/sessions'; describe( 'ai-sessions', () => { let testRoot: string | undefined; From ec7b31a81314aa602e3d9f7e42cfda834e6028d4 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 12 Mar 2026 19:36:17 +0100 Subject: [PATCH 15/33] Move test files inside ai command tests folder --- apps/cli/commands/{ => ai}/tests/ai.test.ts | 0 .../ai-sessions.test.ts => commands/ai/tests/sessions.test.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename apps/cli/commands/{ => ai}/tests/ai.test.ts (100%) rename apps/cli/{lib/tests/ai-sessions.test.ts => commands/ai/tests/sessions.test.ts} (100%) diff --git a/apps/cli/commands/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts similarity index 100% rename from apps/cli/commands/tests/ai.test.ts rename to apps/cli/commands/ai/tests/ai.test.ts diff --git a/apps/cli/lib/tests/ai-sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts similarity index 100% rename from apps/cli/lib/tests/ai-sessions.test.ts rename to apps/cli/commands/ai/tests/sessions.test.ts From f9174b7f3f9c0fe6283c38b5b645489042b11b33 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 12 Mar 2026 20:54:07 +0100 Subject: [PATCH 16/33] Separate sessions commands into separate files --- apps/cli/commands/ai/sessions/delete.ts | 59 ++++++ .../ai/{sessions.ts => sessions/helpers.ts} | 168 +----------------- apps/cli/commands/ai/sessions/list.ts | 51 ++++++ apps/cli/commands/ai/sessions/resume.ts | 71 ++++++++ apps/cli/commands/ai/tests/ai.test.ts | 8 +- 5 files changed, 190 insertions(+), 167 deletions(-) create mode 100644 apps/cli/commands/ai/sessions/delete.ts rename apps/cli/commands/ai/{sessions.ts => sessions/helpers.ts} (58%) create mode 100644 apps/cli/commands/ai/sessions/list.ts create mode 100644 apps/cli/commands/ai/sessions/resume.ts diff --git a/apps/cli/commands/ai/sessions/delete.ts b/apps/cli/commands/ai/sessions/delete.ts new file mode 100644 index 0000000000..0aff012936 --- /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'; +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.ts b/apps/cli/commands/ai/sessions/helpers.ts similarity index 58% rename from apps/cli/commands/ai/sessions.ts rename to apps/cli/commands/ai/sessions/helpers.ts index 19507a7963..73a8281a7d 100644 --- a/apps/cli/commands/ai/sessions.ts +++ b/apps/cli/commands/ai/sessions/helpers.ts @@ -1,17 +1,7 @@ import { select } from '@inquirer/prompts'; import { __ } from '@wordpress/i18n'; import chalk from 'chalk'; -import { - deleteAiSession, - listAiSessions, - loadAiSession, - type AiSessionSummary, -} from 'cli/ai/sessions'; -import { runCommand as runAiCommand } from 'cli/commands/ai'; -import { Logger, LoggerError } from 'cli/logger'; -import { StudioArgv } from 'cli/types'; - -const logger = new Logger< string >(); +import { listAiSessions, type AiSessionSummary } from 'cli/ai/sessions'; function formatSessionTimestamp( timestamp: string ): string { const parsed = Date.parse( timestamp ); @@ -127,7 +117,7 @@ function formatSessionCompactLine( return `${ renderedLeft }${ ' '.repeat( padding ) }${ chalk.hex( '#8839ef' )( siteLabel ) }`; } -function displaySessionsCompact( sessions: AiSessionSummary[] ): void { +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 = { @@ -146,22 +136,6 @@ function displaySessionsCompact( sessions: AiSessionSummary[] ): void { } } -async function runListSessionsCommand( format: 'compact' | 'json' ): 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 ); -} - async function pickSessionInteractively( sessions: AiSessionSummary[], message: string @@ -222,7 +196,7 @@ async function pickSessionInteractively( } } -async function chooseSessionForAction( +export async function chooseSessionForAction( actionLabel: string, noSessionsMessage: string ): Promise< AiSessionSummary | undefined > { @@ -234,139 +208,3 @@ async function chooseSessionForAction( return pickSessionInteractively( sessions, actionLabel ); } - -async function runResumeSessionCommand( - 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, - } ); -} - -async function runDeleteSessionCommand( 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: '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 runListSessionsCommand( argv.format as 'compact' | 'json' ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - logger.reportError( new LoggerError( __( 'Failed to list AI sessions' ), error ) ); - } - } - }, - } ) - .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 runResumeSessionCommand( 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 ); - } - } - }, - } ) - .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 runDeleteSessionCommand( 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/list.ts b/apps/cli/commands/ai/sessions/list.ts new file mode 100644 index 0000000000..05ad7e8c1b --- /dev/null +++ b/apps/cli/commands/ai/sessions/list.ts @@ -0,0 +1,51 @@ +import { __ } from '@wordpress/i18n'; +import { listAiSessions } from 'cli/ai/sessions'; +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..817b836e28 --- /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'; +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 index c918d622a3..013012b587 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import yargs from 'yargs/yargs'; import { AiSessionRecorder, deleteAiSession, listAiSessions, loadAiSession } from 'cli/ai/sessions'; import { registerCommand as registerAiCommand } from 'cli/commands/ai'; -import { registerCommand as registerAiSessionsCommand } from 'cli/commands/ai/sessions'; +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'; @@ -143,7 +145,9 @@ describe( 'CLI: studio ai sessions command', () => { default: true, description: 'Record this AI chat session to disk', } ); - registerAiSessionsCommand( sessionsYargs as StudioArgv ); + 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' ); From 322a835718119694170685c84ed4ea268930007c Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 12 Mar 2026 21:19:30 +0100 Subject: [PATCH 17/33] Separate sessions into submodules --- apps/cli/ai/sessions.ts | 486 -------------------- apps/cli/ai/sessions/parser.ts | 166 +++++++ apps/cli/ai/sessions/paths.ts | 17 + apps/cli/ai/sessions/recorder.ts | 160 +++++++ apps/cli/ai/sessions/replay.ts | 65 +++ apps/cli/ai/sessions/store.ts | 140 ++++++ apps/cli/ai/sessions/summary.ts | 80 ++++ apps/cli/ai/sessions/types.ts | 86 ++++ apps/cli/commands/ai/index.ts | 3 +- apps/cli/commands/ai/sessions/delete.ts | 2 +- apps/cli/commands/ai/sessions/helpers.ts | 3 +- apps/cli/commands/ai/sessions/list.ts | 2 +- apps/cli/commands/ai/sessions/resume.ts | 2 +- apps/cli/commands/ai/tests/ai.test.ts | 32 +- apps/cli/commands/ai/tests/sessions.test.ts | 9 +- 15 files changed, 747 insertions(+), 506 deletions(-) delete mode 100644 apps/cli/ai/sessions.ts create mode 100644 apps/cli/ai/sessions/parser.ts create mode 100644 apps/cli/ai/sessions/paths.ts create mode 100644 apps/cli/ai/sessions/recorder.ts create mode 100644 apps/cli/ai/sessions/replay.ts create mode 100644 apps/cli/ai/sessions/store.ts create mode 100644 apps/cli/ai/sessions/summary.ts create mode 100644 apps/cli/ai/sessions/types.ts diff --git a/apps/cli/ai/sessions.ts b/apps/cli/ai/sessions.ts deleted file mode 100644 index 8dbde331d5..0000000000 --- a/apps/cli/ai/sessions.ts +++ /dev/null @@ -1,486 +0,0 @@ -import crypto from 'crypto'; -import fs from 'fs/promises'; -import path from 'path'; -import { getAppdataDirectory } from 'cli/lib/appdata'; - -export type TurnStatus = 'success' | 'error' | 'max_turns' | 'interrupted'; - -export type AssistantMessageBlock = - | { - type: 'text'; - text: string; - } - | { - type: 'tool_use'; - name: string; - detail?: string; - }; - -export type AiSessionEvent = - | { - type: 'session.started'; - timestamp: string; - version: 1; - sessionId: string; - } - | { - type: 'session.linked'; - timestamp: string; - agentSessionId: string; - } - | { - type: 'site.selected'; - timestamp: string; - siteName: string; - sitePath: string; - } - | { - type: 'user.message'; - timestamp: string; - text: string; - source: 'prompt' | 'ask_user'; - sitePath?: string; - } - | { - type: 'assistant.message'; - timestamp: string; - blocks: AssistantMessageBlock[]; - } - | { - type: 'tool.result'; - timestamp: string; - ok: boolean; - text: string; - } - | { - 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[]; -} - -export function getAiSessionsRootDirectory(): string { - return path.join( getAppdataDirectory(), 'sessions' ); -} - -function formatDatePart( value: number ): string { - return String( value ).padStart( 2, '0' ); -} - -function toSortableTimestampPrefix( date: Date ): string { - return date - .toISOString() - .replace( /:/g, '-' ) - .replace( /\.\d{3}Z$/, '' ); -} - -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 ); -} - -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 = `${ toSortableTimestampPrefix( startedAt ) }-${ sessionId }.jsonl`; - 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 recordSiteSelected( site: { name: string; path: string } ): Promise< void > { - await this.appendEvent( { - type: 'site.selected', - timestamp: toIsoTimestamp(), - siteName: site.name, - sitePath: site.path, - } ); - } - - 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 recordAssistantMessage( blocks: AssistantMessageBlock[] ): Promise< void > { - if ( blocks.length === 0 ) { - return; - } - - await this.appendEvent( { - type: 'assistant.message', - timestamp: toIsoTimestamp(), - blocks, - } ); - } - - async recordToolResult( options: { ok: boolean; text: string } ): Promise< void > { - await this.appendEvent( { - type: 'tool.result', - timestamp: toIsoTimestamp(), - ok: options.ok, - text: options.text, - } ); - } - - async recordToolProgress( message: string ): Promise< void > { - if ( ! message.trim() ) { - return; - } - - await this.appendEvent( { - type: 'tool.progress', - 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', - } ); - } -} - -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; -} - -function getSessionIdFromPath( filePath: string ): string { - const fileName = path.basename( filePath, '.jsonl' ); - const uuidMatch = fileName.match( - /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i - ); - return uuidMatch?.[ 1 ] ?? fileName; -} - -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 readAiSessionSummaryFromFile( - filePath: string -): Promise< AiSessionSummary | undefined > { - const events = await readAiSessionEventsFromFile( filePath ); - if ( events.length === 0 ) { - return undefined; - } - - const linkedAgentSessionIds: string[] = []; - let createdAt: string | undefined; - let updatedAt: string | undefined; - let sessionId = getSessionIdFromPath( 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, - }; -} - -export async function listAiSessions(): Promise< AiSessionSummary[] > { - const sessionFiles = await listSessionFilesRecursively( getAiSessionsRootDirectory() ); - const results = await Promise.allSettled( - sessionFiles.map( ( filePath ) => readAiSessionSummaryFromFile( filePath ) ) - ); - - 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 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 ? ', …' : '' - }` - ); - } - - const summary = candidates[ 0 ]; - const events = await readAiSessionEventsFromFile( summary.filePath ); - return { summary, events }; -} - -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 deleteAiSession( 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 ? ', …' : '' - }` - ); - } - - const sessionToDelete = candidates[ 0 ]; - await fs.rm( sessionToDelete.filePath, { force: false } ); - await pruneEmptySessionDirectories( path.dirname( sessionToDelete.filePath ) ); - - return sessionToDelete; -} diff --git a/apps/cli/ai/sessions/parser.ts b/apps/cli/ai/sessions/parser.ts new file mode 100644 index 0000000000..e7d23f1493 --- /dev/null +++ b/apps/cli/ai/sessions/parser.ts @@ -0,0 +1,166 @@ +import type { AssistantMessageBlock } from './types'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; + +type ToolDetailResolver = ( name: string, input: Record< string, unknown > ) => string; + +export function extractAssistantMessageBlocks( + message: SDKMessage, + resolveToolDetail: ToolDetailResolver +): AssistantMessageBlock[] { + if ( message.type !== 'assistant' ) { + return []; + } + + const blocks: AssistantMessageBlock[] = []; + for ( const block of message.message.content ) { + if ( block.type === 'text' && block.text ) { + blocks.push( { + type: 'text', + text: block.text, + } ); + } + + if ( block.type === 'tool_use' && block.name ) { + const detail = + block.input && typeof block.input === 'object' + ? resolveToolDetail( block.name, block.input as Record< string, unknown > ) + : ''; + blocks.push( { + type: 'tool_use', + name: block.name, + detail: detail || undefined, + } ); + } + } + + return blocks; +} + +function toToolResultText( value: unknown ): string { + if ( Array.isArray( value ) ) { + const lines = value + .map( ( item ) => { + if ( typeof item === 'string' ) { + return item; + } + + if ( item && typeof item === 'object' ) { + const typedItem = item as { type?: unknown; text?: unknown }; + if ( typedItem.type === 'text' && typeof typedItem.text === 'string' ) { + return typedItem.text; + } + + try { + return JSON.stringify( item, null, 2 ); + } catch { + return String( item ); + } + } + + return String( item ); + } ) + .map( ( line ) => line.trim() ) + .filter( ( line ) => line.length > 0 ); + + return lines.join( '\n' ); + } + + if ( typeof value === 'string' ) { + return value.trim(); + } + + if ( value === null || value === undefined ) { + return ''; + } + + try { + return JSON.stringify( value, null, 2 ); + } catch { + return String( value ); + } +} + +export function extractToolResult( + message: SDKMessage +): { ok: boolean; text: string } | undefined { + if ( message.type !== 'user' ) { + return undefined; + } + + const rawResult = message.tool_use_result; + if ( ! rawResult ) { + return undefined; + } + + if ( typeof rawResult !== 'object' ) { + const text = String( rawResult ).trim(); + return { + ok: true, + text, + }; + } + + const typedResult = rawResult as { + content?: unknown; + isError?: unknown; + is_error?: unknown; + }; + const isError = typedResult.isError === true || typedResult.is_error === true; + const textFromContent = toToolResultText( typedResult.content ); + + return { + ok: ! isError, + text: textFromContent, + }; +} + +function toReplayToolInput( _name: string, detail?: string ): Record< string, unknown > { + if ( ! detail ) { + return {}; + } + + return { detail }; +} + +export function toReplayAssistantMessage( blocks: AssistantMessageBlock[] ): SDKMessage { + return { + type: 'assistant', + message: { + content: blocks.map( ( block, index ) => { + if ( block.type === 'text' ) { + return { + type: 'text', + text: block.text, + }; + } + + return { + type: 'tool_use', + id: `replay-tool-${ index }`, + name: block.name, + input: toReplayToolInput( block.name, block.detail ), + }; + } ), + }, + } as SDKMessage; +} + +export function toReplayToolResultMessage( options: { ok: boolean; text: string } ): SDKMessage { + const normalizedText = options.text.trim(); + const content = ! normalizedText + ? [] + : [ + { + type: 'text', + text: options.text, + }, + ]; + + return { + type: 'user', + tool_use_result: { + isError: ! options.ok, + content, + }, + } as SDKMessage; +} diff --git a/apps/cli/ai/sessions/paths.ts b/apps/cli/ai/sessions/paths.ts new file mode 100644 index 0000000000..da69fb1796 --- /dev/null +++ b/apps/cli/ai/sessions/paths.ts @@ -0,0 +1,17 @@ +import path from 'path'; +import { getAppdataDirectory } from 'cli/lib/appdata'; + +function formatDatePart( value: number ): string { + return String( value ).padStart( 2, '0' ); +} + +export function getAiSessionsRootDirectory(): string { + return path.join( getAppdataDirectory(), 'sessions' ); +} + +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..14fdcfde48 --- /dev/null +++ b/apps/cli/ai/sessions/recorder.ts @@ -0,0 +1,160 @@ +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import { getAiSessionsDirectoryForDate } from './paths'; +import type { AiSessionEvent, AssistantMessageBlock, TurnStatus } from './types'; + +function toSortableTimestampPrefix( date: Date ): string { + return date + .toISOString() + .replace( /:/g, '-' ) + .replace( /\.\d{3}Z$/, '' ); +} + +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 = `${ toSortableTimestampPrefix( startedAt ) }-${ sessionId }.jsonl`; + 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 recordSiteSelected( site: { name: string; path: string } ): Promise< void > { + await this.appendEvent( { + type: 'site.selected', + timestamp: toIsoTimestamp(), + siteName: site.name, + sitePath: site.path, + } ); + } + + 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 recordAssistantMessage( blocks: AssistantMessageBlock[] ): Promise< void > { + if ( blocks.length === 0 ) { + return; + } + + await this.appendEvent( { + type: 'assistant.message', + timestamp: toIsoTimestamp(), + blocks, + } ); + } + + async recordToolResult( options: { ok: boolean; text: string } ): Promise< void > { + await this.appendEvent( { + type: 'tool.result', + timestamp: toIsoTimestamp(), + ok: options.ok, + text: options.text, + } ); + } + + async recordToolProgress( message: string ): Promise< void > { + if ( ! message.trim() ) { + return; + } + + await this.appendEvent( { + type: 'tool.progress', + 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..d0ac59731d --- /dev/null +++ b/apps/cli/ai/sessions/replay.ts @@ -0,0 +1,65 @@ +import path from 'path'; +import { AiChatUI } from 'cli/ai/ui'; +import { toReplayAssistantMessage, toReplayToolResultMessage } from './parser'; +import type { AiSessionEvent } from './types'; + +export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): void { + ui.prepareForReplay(); + + try { + for ( const event of events ) { + if ( event.type === 'site.selected' ) { + ui.setActiveSite( + { + name: event.siteName, + path: event.sitePath, + running: false, + }, + { announce: false, emitEvent: false } + ); + continue; + } + + if ( event.type === 'user.message' ) { + if ( event.sitePath && ( ! ui.activeSite || ui.activeSite.path !== event.sitePath ) ) { + ui.setActiveSite( + { + name: path.basename( event.sitePath ), + path: event.sitePath, + running: false, + }, + { announce: false, emitEvent: false } + ); + } + ui.addUserMessage( event.text ); + continue; + } + + if ( event.type === 'assistant.message' ) { + ui.handleMessage( toReplayAssistantMessage( event.blocks ) ); + continue; + } + + if ( event.type === 'tool.result' ) { + ui.handleMessage( toReplayToolResultMessage( event ) ); + 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' ) { + ui.endAgentTurn(); + } + } + } finally { + 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..9a2ba8a643 --- /dev/null +++ b/apps/cli/ai/sessions/summary.ts @@ -0,0 +1,80 @@ +import fs from 'fs/promises'; +import path from 'path'; +import type { AiSessionEvent, AiSessionSummary } from './types'; + +function getSessionIdFromPath( filePath: string ): string { + const fileName = path.basename( filePath, '.jsonl' ); + const uuidMatch = fileName.match( + /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i + ); + return uuidMatch?.[ 1 ] ?? fileName; +} + +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 = getSessionIdFromPath( 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..fccc8f6fde --- /dev/null +++ b/apps/cli/ai/sessions/types.ts @@ -0,0 +1,86 @@ +export type TurnStatus = 'success' | 'error' | 'max_turns' | 'interrupted'; + +export type AssistantMessageBlock = + | { + type: 'text'; + text: string; + } + | { + type: 'tool_use'; + name: string; + detail?: string; + }; + +export type AiSessionEvent = + | { + type: 'session.started'; + timestamp: string; + version: 1; + sessionId: string; + } + | { + type: 'session.linked'; + timestamp: string; + agentSessionId: string; + } + | { + type: 'site.selected'; + timestamp: string; + siteName: string; + sitePath: string; + } + | { + type: 'user.message'; + timestamp: string; + text: string; + source: 'prompt' | 'ask_user'; + sitePath?: string; + } + | { + type: 'assistant.message'; + timestamp: string; + blocks: AssistantMessageBlock[]; + } + | { + type: 'tool.result'; + timestamp: string; + ok: boolean; + text: string; + } + | { + 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/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index 8af9647ee6..be58065905 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -25,7 +25,6 @@ import { runCommand as runLogoutCommand } from 'cli/commands/auth/logout'; import { getAnthropicApiKey, getAuthToken } from 'cli/lib/appdata'; import { Logger, LoggerError, setProgressCallback } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; const logger = new Logger< string >(); @@ -309,7 +308,7 @@ export async function runCommand(): Promise< void > { try { for await ( const message of agentQuery ) { - const assistantBlocks = extractAssistantMessageBlocks( message ); + const assistantBlocks = extractAssistantMessageBlocks( message, getToolDetail ); if ( assistantBlocks.length > 0 ) { await persist( ( recorder ) => recorder.recordAssistantMessage( assistantBlocks ) ); } diff --git a/apps/cli/commands/ai/sessions/delete.ts b/apps/cli/commands/ai/sessions/delete.ts index 0aff012936..8ee4b690a6 100644 --- a/apps/cli/commands/ai/sessions/delete.ts +++ b/apps/cli/commands/ai/sessions/delete.ts @@ -1,5 +1,5 @@ import { __ } from '@wordpress/i18n'; -import { deleteAiSession, listAiSessions } from 'cli/ai/sessions'; +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'; diff --git a/apps/cli/commands/ai/sessions/helpers.ts b/apps/cli/commands/ai/sessions/helpers.ts index 73a8281a7d..5ef90b556e 100644 --- a/apps/cli/commands/ai/sessions/helpers.ts +++ b/apps/cli/commands/ai/sessions/helpers.ts @@ -1,7 +1,8 @@ import { select } from '@inquirer/prompts'; import { __ } from '@wordpress/i18n'; import chalk from 'chalk'; -import { listAiSessions, type AiSessionSummary } from 'cli/ai/sessions'; +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 ); diff --git a/apps/cli/commands/ai/sessions/list.ts b/apps/cli/commands/ai/sessions/list.ts index 05ad7e8c1b..5cf337419a 100644 --- a/apps/cli/commands/ai/sessions/list.ts +++ b/apps/cli/commands/ai/sessions/list.ts @@ -1,5 +1,5 @@ import { __ } from '@wordpress/i18n'; -import { listAiSessions } from 'cli/ai/sessions'; +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'; diff --git a/apps/cli/commands/ai/sessions/resume.ts b/apps/cli/commands/ai/sessions/resume.ts index 817b836e28..b33c215547 100644 --- a/apps/cli/commands/ai/sessions/resume.ts +++ b/apps/cli/commands/ai/sessions/resume.ts @@ -1,5 +1,5 @@ import { __ } from '@wordpress/i18n'; -import { listAiSessions, loadAiSession } from 'cli/ai/sessions'; +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'; diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index 013012b587..fa45be8680 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -1,6 +1,8 @@ +import { __ } from '@wordpress/i18n'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import yargs from 'yargs/yargs'; -import { AiSessionRecorder, deleteAiSession, listAiSessions, loadAiSession } from 'cli/ai/sessions'; +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'; @@ -102,7 +104,7 @@ vi.mock( 'cli/ai/ui', () => ( { typeof input.detail === 'string' ? input.detail : '', } ) ); -vi.mock( 'cli/ai/sessions', () => { +vi.mock( 'cli/ai/sessions/recorder', () => { class MockAiSessionRecorder { static create = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); static open = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); @@ -118,12 +120,24 @@ vi.mock( 'cli/ai/sessions', () => { return { AiSessionRecorder: MockAiSessionRecorder, - listAiSessions: vi.fn(), - loadAiSession: vi.fn(), - deleteAiSession: vi.fn(), }; } ); +vi.mock( 'cli/ai/sessions/store', () => ( { + listAiSessions: vi.fn(), + loadAiSession: vi.fn(), + deleteAiSession: vi.fn(), +} ) ); + +vi.mock( 'cli/ai/sessions/parser', () => ( { + extractAssistantMessageBlocks: vi.fn().mockReturnValue( [] ), + extractToolResult: vi.fn().mockReturnValue( undefined ), +} ) ); + +vi.mock( 'cli/ai/sessions/replay', () => ( { + replaySessionHistory: vi.fn(), +} ) ); + describe( 'CLI: studio ai sessions command', () => { beforeEach( () => { vi.clearAllMocks(); @@ -133,9 +147,9 @@ describe( 'CLI: studio ai sessions command', () => { function buildParser(): StudioArgv { const parser = yargs( [] ).scriptName( 'studio' ).strict().exitProcess( false ) as StudioArgv; - parser.command( 'ai', 'AI-powered WordPress assistant', ( aiYargs ) => { + parser.command( 'ai', __( 'AI-powered WordPress assistant' ), ( aiYargs ) => { registerAiCommand( aiYargs as StudioArgv ); - aiYargs.command( 'sessions', 'Manage AI sessions', ( sessionsYargs ) => { + aiYargs.command( 'sessions', __( 'Manage AI sessions' ), ( sessionsYargs ) => { sessionsYargs .option( 'path', { hidden: true, @@ -143,14 +157,14 @@ describe( 'CLI: studio ai sessions command', () => { .option( 'session-persistence', { type: 'boolean', default: true, - description: 'Record this AI chat session to disk', + 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' ); + .demandCommand( 1, __( 'You must provide a valid ai sessions command' ) ); } ); aiYargs.version( false ); } ); diff --git a/apps/cli/commands/ai/tests/sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts index 6468e5d20f..677cb8d9ce 100644 --- a/apps/cli/commands/ai/tests/sessions.test.ts +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -2,15 +2,14 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; +import { getAiSessionsDirectoryForDate, getAiSessionsRootDirectory } from 'cli/ai/sessions/paths'; +import { AiSessionRecorder } from 'cli/ai/sessions/recorder'; import { - AiSessionRecorder, deleteAiSession, - getAiSessionsDirectoryForDate, - getAiSessionsRootDirectory, - loadAiSession, listAiSessions, + loadAiSession, readAiSessionEventsFromFile, -} from 'cli/ai/sessions'; +} from 'cli/ai/sessions/store'; describe( 'ai-sessions', () => { let testRoot: string | undefined; From 1573e19e4d0798fa72e257ecebfc8298e3a26516 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 12 Mar 2026 22:48:14 +0100 Subject: [PATCH 18/33] Restore sessions feature after rebase --- apps/cli/ai/ui.ts | 33 ++++- apps/cli/commands/ai/index.ts | 176 ++++++++++---------------- apps/cli/commands/ai/tests/ai.test.ts | 4 + 3 files changed, 101 insertions(+), 112 deletions(-) diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 6c1830a3dd..77fcf52722 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -451,6 +451,8 @@ 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 pendingToolCalls = new Map< string, { name: string; input: Record< string, unknown > } @@ -548,6 +550,27 @@ export class AiChatUI { this.siteSelectedCallback = fn; } + prepareForReplay(): void { + this.replayMode = true; + this.hideLoader(); + this.currentMarkdown = null; + this.currentResponseText = ''; + } + + finishReplay(): void { + this.replayMode = false; + this.hideLoader(); + this.currentMarkdown = null; + this.currentResponseText = ''; + } + + showAgentQuestion( + question: string, + options: Array< { label: string; description: string } > + ): void { + this.showInfo( `${ question } — ${ options.map( ( option ) => option.label ).join( ' / ' ) }` ); + } + constructor() { const terminal = new ProcessTerminal(); this.tui = new TUI( terminal ); @@ -898,12 +921,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(); } diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index be58065905..f1e89cfaf2 100644 --- a/apps/cli/commands/ai/index.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 { extractAssistantMessageBlocks, extractToolResult } from 'cli/ai/sessions/parser'; +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, @@ -19,7 +29,7 @@ import { AI_CHAT_MODEL_COMMAND, AI_CHAT_PROVIDER_COMMAND, } from 'cli/ai/slash-commands'; -import { AiChatUI } from 'cli/ai/ui'; +import { AiChatUI, getToolDetail } from 'cli/ai/ui'; import { runCommand as runLoginCommand } from 'cli/commands/auth/login'; import { runCommand as runLogoutCommand } from 'cli/commands/auth/logout'; import { getAnthropicApiKey, getAuthToken } from 'cli/lib/appdata'; @@ -43,117 +53,12 @@ function getErrorMessage( error: unknown ): string { return String( error ); } -function extractAssistantMessageBlocks( message: SDKMessage ): AssistantMessageBlock[] { - if ( message.type !== 'assistant' ) { - return []; - } - - const blocks: AssistantMessageBlock[] = []; - for ( const block of message.message.content ) { - if ( block.type === 'text' && block.text ) { - blocks.push( { - type: 'text', - text: block.text, - } ); - } - - if ( block.type === 'tool_use' && block.name ) { - const detail = - block.input && typeof block.input === 'object' - ? getToolDetail( block.name, block.input as Record< string, unknown > ) - : ''; - blocks.push( { - type: 'tool_use', - name: block.name, - detail: detail || undefined, - } ); - } - } - - return blocks; -} - -function toToolResultText( value: unknown ): string { - if ( Array.isArray( value ) ) { - const lines = value - .map( ( item ) => { - if ( typeof item === 'string' ) { - return item; - } - - if ( item && typeof item === 'object' ) { - const typedItem = item as { type?: unknown; text?: unknown }; - if ( typedItem.type === 'text' && typeof typedItem.text === 'string' ) { - return typedItem.text; - } - - try { - return JSON.stringify( item, null, 2 ); - } catch { - return String( item ); - } - } - - return String( item ); - } ) - .map( ( line ) => line.trim() ) - .filter( ( line ) => line.length > 0 ); - - return lines.join( '\n' ); - } - - if ( typeof value === 'string' ) { - return value.trim(); - } - - if ( value === null || value === undefined ) { - return ''; - } - - try { - return JSON.stringify( value, null, 2 ); - } catch { - return String( value ); - } -} - -function extractToolResult( message: SDKMessage ): { ok: boolean; text: string } | undefined { - if ( message.type !== 'user' ) { - return undefined; - } - - const rawResult = message.tool_use_result; - if ( ! rawResult ) { - return undefined; - } - - if ( typeof rawResult !== 'object' ) { - const text = String( rawResult ).trim(); - return { - ok: true, - text, - }; - } - - const typedResult = rawResult as { - content?: unknown; - isError?: unknown; - is_error?: unknown; - }; - const isError = typedResult.isError === true || typedResult.is_error === true; - const textFromContent = toToolResultText( typedResult.content ); - - return { - ok: ! isError, - text: textFromContent, - }; -} - -export async function runCommand(): Promise< void > { +export async function runCommand( + options: { resumeSession?: LoadedAiSession; noSessionPersistence?: boolean } = {} +): Promise< void > { const ui = new AiChatUI(); let currentProvider: AiProviderId = await resolveInitialAiProvider(); ui.currentProvider = currentProvider; - setProgressCallback( ( message ) => ui.setLoaderMessage( message ) ); ui.start(); ui.showWelcome(); @@ -203,6 +108,23 @@ export async function runCommand(): Promise< void > { let currentModel: AiModelId = DEFAULT_MODEL; + ui.onSiteSelected = ( site ) => { + void persist( ( recorder ) => + recorder.recordSiteSelected( { + name: site.name, + path: site.path, + } ) + ); + }; + + 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, options?: { force?: boolean } @@ -267,6 +189,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(); diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index fa45be8680..2fa8444bb4 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -19,6 +19,10 @@ vi.mock( 'cli/lib/appdata', () => ( { saveAnthropicApiKey: vi.fn(), } ) ); +vi.mock( 'cli/ai/auth', () => ( { + resolveInitialAiProvider: vi.fn().mockResolvedValue( 'anthropic-api-key' ), +} ) ); + vi.mock( 'cli/logger', () => ( { Logger: class { reportStart = vi.fn(); From 971e61183cb821212f015a1f1b921fed6a4a4dbd Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 13 Mar 2026 00:00:39 +0100 Subject: [PATCH 19/33] Stop differentiating assistant.message and tool.result --- apps/cli/ai/sessions/parser.ts | 166 -------------------- apps/cli/ai/sessions/recorder.ts | 28 +--- apps/cli/ai/sessions/replay.ts | 17 +- apps/cli/ai/sessions/types.ts | 23 +-- apps/cli/ai/ui.ts | 65 +++++--- apps/cli/commands/ai/index.ts | 48 +++--- apps/cli/commands/ai/tests/ai.test.ts | 8 +- apps/cli/commands/ai/tests/sessions.test.ts | 118 ++++++++++++-- 8 files changed, 193 insertions(+), 280 deletions(-) delete mode 100644 apps/cli/ai/sessions/parser.ts diff --git a/apps/cli/ai/sessions/parser.ts b/apps/cli/ai/sessions/parser.ts deleted file mode 100644 index e7d23f1493..0000000000 --- a/apps/cli/ai/sessions/parser.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { AssistantMessageBlock } from './types'; -import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; - -type ToolDetailResolver = ( name: string, input: Record< string, unknown > ) => string; - -export function extractAssistantMessageBlocks( - message: SDKMessage, - resolveToolDetail: ToolDetailResolver -): AssistantMessageBlock[] { - if ( message.type !== 'assistant' ) { - return []; - } - - const blocks: AssistantMessageBlock[] = []; - for ( const block of message.message.content ) { - if ( block.type === 'text' && block.text ) { - blocks.push( { - type: 'text', - text: block.text, - } ); - } - - if ( block.type === 'tool_use' && block.name ) { - const detail = - block.input && typeof block.input === 'object' - ? resolveToolDetail( block.name, block.input as Record< string, unknown > ) - : ''; - blocks.push( { - type: 'tool_use', - name: block.name, - detail: detail || undefined, - } ); - } - } - - return blocks; -} - -function toToolResultText( value: unknown ): string { - if ( Array.isArray( value ) ) { - const lines = value - .map( ( item ) => { - if ( typeof item === 'string' ) { - return item; - } - - if ( item && typeof item === 'object' ) { - const typedItem = item as { type?: unknown; text?: unknown }; - if ( typedItem.type === 'text' && typeof typedItem.text === 'string' ) { - return typedItem.text; - } - - try { - return JSON.stringify( item, null, 2 ); - } catch { - return String( item ); - } - } - - return String( item ); - } ) - .map( ( line ) => line.trim() ) - .filter( ( line ) => line.length > 0 ); - - return lines.join( '\n' ); - } - - if ( typeof value === 'string' ) { - return value.trim(); - } - - if ( value === null || value === undefined ) { - return ''; - } - - try { - return JSON.stringify( value, null, 2 ); - } catch { - return String( value ); - } -} - -export function extractToolResult( - message: SDKMessage -): { ok: boolean; text: string } | undefined { - if ( message.type !== 'user' ) { - return undefined; - } - - const rawResult = message.tool_use_result; - if ( ! rawResult ) { - return undefined; - } - - if ( typeof rawResult !== 'object' ) { - const text = String( rawResult ).trim(); - return { - ok: true, - text, - }; - } - - const typedResult = rawResult as { - content?: unknown; - isError?: unknown; - is_error?: unknown; - }; - const isError = typedResult.isError === true || typedResult.is_error === true; - const textFromContent = toToolResultText( typedResult.content ); - - return { - ok: ! isError, - text: textFromContent, - }; -} - -function toReplayToolInput( _name: string, detail?: string ): Record< string, unknown > { - if ( ! detail ) { - return {}; - } - - return { detail }; -} - -export function toReplayAssistantMessage( blocks: AssistantMessageBlock[] ): SDKMessage { - return { - type: 'assistant', - message: { - content: blocks.map( ( block, index ) => { - if ( block.type === 'text' ) { - return { - type: 'text', - text: block.text, - }; - } - - return { - type: 'tool_use', - id: `replay-tool-${ index }`, - name: block.name, - input: toReplayToolInput( block.name, block.detail ), - }; - } ), - }, - } as SDKMessage; -} - -export function toReplayToolResultMessage( options: { ok: boolean; text: string } ): SDKMessage { - const normalizedText = options.text.trim(); - const content = ! normalizedText - ? [] - : [ - { - type: 'text', - text: options.text, - }, - ]; - - return { - type: 'user', - tool_use_result: { - isError: ! options.ok, - content, - }, - } as SDKMessage; -} diff --git a/apps/cli/ai/sessions/recorder.ts b/apps/cli/ai/sessions/recorder.ts index 14fdcfde48..2edbcf8789 100644 --- a/apps/cli/ai/sessions/recorder.ts +++ b/apps/cli/ai/sessions/recorder.ts @@ -2,7 +2,8 @@ import crypto from 'crypto'; import fs from 'fs/promises'; import path from 'path'; import { getAiSessionsDirectoryForDate } from './paths'; -import type { AiSessionEvent, AssistantMessageBlock, TurnStatus } from './types'; +import type { AiSessionEvent, TurnStatus } from './types'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; function toSortableTimestampPrefix( date: Date ): string { return date @@ -96,35 +97,22 @@ export class AiSessionRecorder { } ); } - async recordAssistantMessage( blocks: AssistantMessageBlock[] ): Promise< void > { - if ( blocks.length === 0 ) { - return; - } - - await this.appendEvent( { - type: 'assistant.message', - timestamp: toIsoTimestamp(), - blocks, - } ); - } - - async recordToolResult( options: { ok: boolean; text: string } ): Promise< void > { + async recordSdkMessage( message: SDKMessage, timestamp?: string ): Promise< void > { await this.appendEvent( { - type: 'tool.result', - timestamp: toIsoTimestamp(), - ok: options.ok, - text: options.text, + type: 'sdk.message', + timestamp: timestamp ?? toIsoTimestamp(), + message, } ); } - async recordToolProgress( message: string ): Promise< void > { + async recordToolProgress( message: string, timestamp?: string ): Promise< void > { if ( ! message.trim() ) { return; } await this.appendEvent( { type: 'tool.progress', - timestamp: toIsoTimestamp(), + timestamp: timestamp ?? toIsoTimestamp(), message, } ); } diff --git a/apps/cli/ai/sessions/replay.ts b/apps/cli/ai/sessions/replay.ts index d0ac59731d..d3b567153e 100644 --- a/apps/cli/ai/sessions/replay.ts +++ b/apps/cli/ai/sessions/replay.ts @@ -1,6 +1,5 @@ import path from 'path'; import { AiChatUI } from 'cli/ai/ui'; -import { toReplayAssistantMessage, toReplayToolResultMessage } from './parser'; import type { AiSessionEvent } from './types'; export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): void { @@ -8,6 +7,8 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): try { for ( const event of events ) { + ui.setReplayTimestamp( event.timestamp ); + if ( event.type === 'site.selected' ) { ui.setActiveSite( { @@ -21,6 +22,10 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): } if ( event.type === 'user.message' ) { + if ( event.source === 'ask_user' ) { + continue; + } + if ( event.sitePath && ( ! ui.activeSite || ui.activeSite.path !== event.sitePath ) ) { ui.setActiveSite( { @@ -31,17 +36,13 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): { announce: false, emitEvent: false } ); } + ui.beginAgentTurn(); ui.addUserMessage( event.text ); continue; } - if ( event.type === 'assistant.message' ) { - ui.handleMessage( toReplayAssistantMessage( event.blocks ) ); - continue; - } - - if ( event.type === 'tool.result' ) { - ui.handleMessage( toReplayToolResultMessage( event ) ); + if ( event.type === 'sdk.message' ) { + ui.handleMessage( event.message ); continue; } diff --git a/apps/cli/ai/sessions/types.ts b/apps/cli/ai/sessions/types.ts index fccc8f6fde..136ea18bb8 100644 --- a/apps/cli/ai/sessions/types.ts +++ b/apps/cli/ai/sessions/types.ts @@ -1,15 +1,6 @@ -export type TurnStatus = 'success' | 'error' | 'max_turns' | 'interrupted'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; -export type AssistantMessageBlock = - | { - type: 'text'; - text: string; - } - | { - type: 'tool_use'; - name: string; - detail?: string; - }; +export type TurnStatus = 'success' | 'error' | 'max_turns' | 'interrupted'; export type AiSessionEvent = | { @@ -37,15 +28,9 @@ export type AiSessionEvent = sitePath?: string; } | { - type: 'assistant.message'; + type: 'sdk.message'; timestamp: string; - blocks: AssistantMessageBlock[]; - } - | { - type: 'tool.result'; - timestamp: string; - ok: boolean; - text: string; + message: SDKMessage; } | { type: 'tool.progress'; diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 77fcf52722..cf515a138d 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -246,12 +246,10 @@ const toolDisplayNames: Record< string, string > = { TodoWrite: 'Update todo list', }; -export function getToolDetail( name: string, input: Record< string, unknown > ): string { - const fallbackDetail = typeof input.detail === 'string' ? input.detail : ''; - +function getToolDetail( name: string, input: Record< string, unknown > ): string { switch ( name ) { case 'mcp__studio__site_create': - return typeof input.name === 'string' ? input.name : fallbackDetail; + return typeof input.name === 'string' ? input.name : ''; case 'mcp__studio__site_info': case 'mcp__studio__site_start': case 'mcp__studio__site_stop': @@ -263,14 +261,14 @@ export function getToolDetail( name: string, input: Record< string, unknown > ): case 'mcp__studio__preview_delete': return typeof input.host === 'string' ? input.host : ''; case 'mcp__studio__wp_cli': - return typeof input.command === 'string' ? `wp ${ input.command }` : fallbackDetail; + return typeof input.command === 'string' ? `wp ${ input.command }` : ''; case 'mcp__studio__validate_blocks': if ( typeof input.filePath === 'string' ) { return input.filePath.split( '/' ).slice( -2 ).join( '/' ); } - return fallbackDetail || 'inline content'; + return 'inline content'; case 'mcp__studio__take_screenshot': - return typeof input.url === 'string' ? input.url : fallbackDetail; + return typeof input.url === 'string' ? input.url : ''; case 'Read': case 'Write': case 'Edit': { @@ -279,21 +277,21 @@ export function getToolDetail( name: string, input: Record< string, unknown > ): const parts = filePath.split( '/' ); return parts.slice( -2 ).join( '/' ); } - return fallbackDetail; + return ''; } case 'Bash': return typeof input.command === 'string' ? input.command.length > 60 ? input.command.slice( 0, 57 ) + '…' : input.command - : fallbackDetail; + : ''; case 'Skill': - return typeof input.skill === 'string' ? input.skill : fallbackDetail; + return typeof input.skill === 'string' ? input.skill : ''; case 'Grep': case 'Glob': - return typeof input.pattern === 'string' ? input.pattern : fallbackDetail; + return typeof input.pattern === 'string' ? input.pattern : ''; default: - return fallbackDetail; + return ''; } } @@ -453,6 +451,7 @@ export class AiChatUI { 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 > } @@ -550,8 +549,27 @@ export class AiChatUI { 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 = ''; @@ -559,6 +577,7 @@ export class AiChatUI { finishReplay(): void { this.replayMode = false; + this.replayTimestampMs = null; this.hideLoader(); this.currentMarkdown = null; this.currentResponseText = ''; @@ -566,9 +585,13 @@ export class AiChatUI { showAgentQuestion( question: string, - options: Array< { label: string; description: string } > + _options: Array< { label: string; description: string } > ): void { - this.showInfo( `${ question } — ${ options.map( ( option ) => option.label ).join( ' / ' ) }` ); + this.hideLoader(); + this.currentMarkdown = null; + this.currentResponseText = ''; + this.messages.addChild( new Text( '\n' + chalk.bold( question ), 0, 0 ) ); + this.tui.requestRender(); } constructor() { @@ -1338,7 +1361,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; @@ -1538,9 +1561,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 ) { @@ -1789,7 +1814,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; @@ -1854,7 +1879,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 ) ); } @@ -1868,7 +1893,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/index.ts b/apps/cli/commands/ai/index.ts index f1e89cfaf2..64a9ffedbd 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -16,7 +16,6 @@ import { saveSelectedAiProvider, } from 'cli/ai/auth'; import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers'; -import { extractAssistantMessageBlocks, extractToolResult } from 'cli/ai/sessions/parser'; import { AiSessionRecorder } from 'cli/ai/sessions/recorder'; import { replaySessionHistory } from 'cli/ai/sessions/replay'; import { type LoadedAiSession, type TurnStatus } from 'cli/ai/sessions/types'; @@ -29,7 +28,7 @@ import { AI_CHAT_MODEL_COMMAND, AI_CHAT_PROVIDER_COMMAND, } from 'cli/ai/slash-commands'; -import { AiChatUI, getToolDetail } from 'cli/ai/ui'; +import { AiChatUI } from 'cli/ai/ui'; import { runCommand as runLoginCommand } from 'cli/commands/auth/login'; import { runCommand as runLogoutCommand } from 'cli/commands/auth/logout'; import { getAnthropicApiKey, getAuthToken } from 'cli/lib/appdata'; @@ -65,6 +64,7 @@ export async function runCommand( let sessionRecorder: AiSessionRecorder | undefined; let didDisableSessionPersistence = options.noSessionPersistence === true; let sessionId: string | undefined = options.resumeSession?.summary.agentSessionId; + let persistQueue: Promise< void > = Promise.resolve(); if ( options.noSessionPersistence ) { ui.showInfo( 'Session persistence disabled (--no-session-persistence).' ); @@ -85,25 +85,30 @@ export async function runCommand( } } - const persist = async ( callback: ( recorder: AiSessionRecorder ) => Promise< void > ) => { - if ( ! sessionRecorder ) { - return; - } + const persist = ( callback: ( recorder: AiSessionRecorder ) => Promise< void > ) => { + persistQueue = persistQueue.then( async () => { + if ( ! sessionRecorder ) { + return; + } - try { - await callback( sessionRecorder ); - } catch ( error ) { - sessionRecorder = undefined; - if ( ! didDisableSessionPersistence ) { - didDisableSessionPersistence = true; - ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); + try { + await callback( sessionRecorder ); + } catch ( error ) { + sessionRecorder = undefined; + if ( ! didDisableSessionPersistence ) { + didDisableSessionPersistence = true; + ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); + } } - } + } ); + + return persistQueue; }; setProgressCallback( ( message ) => { + const timestamp = new Date().toISOString(); ui.setLoaderMessage( message ); - void persist( ( recorder ) => recorder.recordToolProgress( message ) ); + void persist( ( recorder ) => recorder.recordToolProgress( message, timestamp ) ); } ); let currentModel: AiModelId = DEFAULT_MODEL; @@ -264,17 +269,9 @@ export async function runCommand( try { for await ( const message of agentQuery ) { - const assistantBlocks = extractAssistantMessageBlocks( message, getToolDetail ); - if ( assistantBlocks.length > 0 ) { - await persist( ( recorder ) => recorder.recordAssistantMessage( assistantBlocks ) ); - } - - const toolResult = extractToolResult( message ); - if ( toolResult ) { - await persist( ( recorder ) => recorder.recordToolResult( toolResult ) ); - } - + 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 ) ); @@ -441,6 +438,7 @@ export async function runCommand( } } } finally { + await persistQueue; ui.stop(); process.exit( 0 ); } diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index 2fa8444bb4..76abf85dd8 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -112,11 +112,10 @@ 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 recordSiteSelected() {} async recordUserMessage() {} - async recordAssistantMessage() {} - async recordToolResult() {} async recordAgentQuestion() {} async recordTurnClosed() {} async recordAgentSessionId() {} @@ -133,11 +132,6 @@ vi.mock( 'cli/ai/sessions/store', () => ( { deleteAiSession: vi.fn(), } ) ); -vi.mock( 'cli/ai/sessions/parser', () => ( { - extractAssistantMessageBlocks: vi.fn().mockReturnValue( [] ), - extractToolResult: vi.fn().mockReturnValue( undefined ), -} ) ); - vi.mock( 'cli/ai/sessions/replay', () => ( { replaySessionHistory: vi.fn(), } ) ); diff --git a/apps/cli/commands/ai/tests/sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts index 677cb8d9ce..2a05af9a70 100644 --- a/apps/cli/commands/ai/tests/sessions.test.ts +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -1,15 +1,18 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +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; @@ -41,14 +44,17 @@ describe( 'ai-sessions', () => { source: 'prompt', sitePath: '/tmp/my-wordpress-website', } ); - await recorder.recordAssistantMessage( [ - { type: 'text', text: 'Sure, I can help with that.' }, - { type: 'tool_use', name: 'Read' }, - ] ); - await recorder.recordToolResult( { - ok: true, - text: 'File read successfully', - } ); + 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', @@ -83,12 +89,11 @@ describe( 'ai-sessions', () => { source: 'prompt', text: 'Help me create a plugin', } ); - expect( events.find( ( event ) => event.type === 'assistant.message' ) ).toMatchObject( { - type: 'assistant.message', - blocks: [ - { type: 'text', text: 'Sure, I can help with that.' }, - { type: 'tool_use', name: 'Read' }, - ], + expect( events.find( ( event ) => event.type === 'sdk.message' ) ).toMatchObject( { + type: 'sdk.message', + message: { + type: 'assistant', + }, } ); expect( events.find( ( event ) => event.type === 'tool.progress' ) ).toMatchObject( { type: 'tool.progress', @@ -314,4 +319,87 @@ describe( 'ai-sessions', () => { 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 ); + } ); } ); From cbaa18876ef4930fe41575d388c611fd54632121 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 13 Mar 2026 00:12:38 +0100 Subject: [PATCH 20/33] Fix blinking dots in resume output --- apps/cli/ai/sessions/replay.ts | 15 ++++++- apps/cli/ai/ui.ts | 4 ++ apps/cli/commands/ai/tests/sessions.test.ts | 45 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/cli/ai/sessions/replay.ts b/apps/cli/ai/sessions/replay.ts index d3b567153e..bc3772f680 100644 --- a/apps/cli/ai/sessions/replay.ts +++ b/apps/cli/ai/sessions/replay.ts @@ -4,6 +4,7 @@ import type { AiSessionEvent } from './types'; export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): void { ui.prepareForReplay(); + let isTurnOpen = false; try { for ( const event of events ) { @@ -26,6 +27,11 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): continue; } + // Defensive close if the previous turn never emitted turn.closed. + if ( isTurnOpen ) { + ui.endAgentTurn(); + } + if ( event.sitePath && ( ! ui.activeSite || ui.activeSite.path !== event.sitePath ) ) { ui.setActiveSite( { @@ -37,6 +43,7 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): ); } ui.beginAgentTurn(); + isTurnOpen = true; ui.addUserMessage( event.text ); continue; } @@ -57,10 +64,16 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): } if ( event.type === 'turn.closed' ) { - ui.endAgentTurn(); + if ( isTurnOpen ) { + ui.endAgentTurn(); + isTurnOpen = false; + } } } } finally { + if ( isTurnOpen ) { + ui.endAgentTurn(); + } ui.finishReplay(); } } diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index cf515a138d..5a92980a58 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -1526,6 +1526,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; diff --git a/apps/cli/commands/ai/tests/sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts index 2a05af9a70..78bbafdda9 100644 --- a/apps/cli/commands/ai/tests/sessions.test.ts +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -402,4 +402,49 @@ describe( 'ai-sessions', () => { 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 ); + } ); } ); From 3c34c7c06243babc5e0afe6e2c05f6f157d02054 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 13 Mar 2026 00:29:11 +0100 Subject: [PATCH 21/33] Save session context (cwd, model, provider) --- apps/cli/ai/agent.ts | 3 +- apps/cli/ai/sessions/context.ts | 61 ++++++++++++++++ apps/cli/ai/sessions/recorder.ts | 16 +++++ apps/cli/ai/sessions/types.ts | 7 ++ apps/cli/commands/ai/index.ts | 21 ++++-- apps/cli/commands/ai/tests/ai.test.ts | 77 ++++++++++++++++++++- apps/cli/commands/ai/tests/sessions.test.ts | 11 +++ 7 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 apps/cli/ai/sessions/context.ts diff --git a/apps/cli/ai/agent.ts b/apps/cli/ai/agent.ts index f953cae088..591167c052 100644 --- a/apps/cli/ai/agent.ts +++ b/apps/cli/ai/agent.ts @@ -16,6 +16,7 @@ export interface AiAgentConfig { prompt: string; env?: Record< string, string >; model?: AiModelId; + cwd?: string; maxTurns?: number; resume?: string; onAskUser?: ( questions: AskUserQuestion[] ) => Promise< Record< string, string > >; @@ -36,7 +37,7 @@ const pathApprovalSession = createPathApprovalSession(); * Caller can iterate messages with `for await` and call `interrupt()` to stop. */ export function startAiAgent( config: AiAgentConfig ): Query { - const { prompt, env, model = DEFAULT_MODEL, maxTurns = 50, resume, onAskUser } = config; + const { prompt, env, model = DEFAULT_MODEL, cwd, maxTurns = 50, resume, onAskUser } = config; const resolvedEnv = env ?? { ...( process.env as Record< string, string > ) }; return query( { diff --git a/apps/cli/ai/sessions/context.ts b/apps/cli/ai/sessions/context.ts new file mode 100644 index 0000000000..e95ef680b5 --- /dev/null +++ b/apps/cli/ai/sessions/context.ts @@ -0,0 +1,61 @@ +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; + cwd?: string; +} + +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.cwd && event.cwd.trim().length > 0 ) { + context.cwd = event.cwd; + } + } + + if ( context.sessionId && context.provider && context.model && context.cwd ) { + break; + } + } + + return context; +} diff --git a/apps/cli/ai/sessions/recorder.ts b/apps/cli/ai/sessions/recorder.ts index 2edbcf8789..5743b2e0bb 100644 --- a/apps/cli/ai/sessions/recorder.ts +++ b/apps/cli/ai/sessions/recorder.ts @@ -4,6 +4,8 @@ import path from 'path'; 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 toSortableTimestampPrefix( date: Date ): string { return date @@ -74,6 +76,20 @@ export class AiSessionRecorder { } ); } + async recordSessionContext( options: { + provider: AiProviderId; + model: AiModelId; + cwd: string; + } ): Promise< void > { + await this.appendEvent( { + type: 'session.context', + timestamp: toIsoTimestamp(), + provider: options.provider, + model: options.model, + cwd: options.cwd, + } ); + } + async recordSiteSelected( site: { name: string; path: string } ): Promise< void > { await this.appendEvent( { type: 'site.selected', diff --git a/apps/cli/ai/sessions/types.ts b/apps/cli/ai/sessions/types.ts index 136ea18bb8..c68ee7b3fb 100644 --- a/apps/cli/ai/sessions/types.ts +++ b/apps/cli/ai/sessions/types.ts @@ -14,6 +14,13 @@ export type AiSessionEvent = timestamp: string; agentSessionId: string; } + | { + type: 'session.context'; + timestamp: string; + provider: string; + model: string; + cwd: string; + } | { type: 'site.selected'; timestamp: string; diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index 64a9ffedbd..4cf6949c3b 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -16,6 +16,7 @@ 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'; @@ -56,14 +57,19 @@ 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; + const currentWorkingDirectory = resumeContext.cwd ?? process.cwd(); ui.currentProvider = currentProvider; + ui.currentModel = currentModel; ui.start(); ui.showWelcome(); let sessionRecorder: AiSessionRecorder | undefined; let didDisableSessionPersistence = options.noSessionPersistence === true; - let sessionId: string | undefined = options.resumeSession?.summary.agentSessionId; + let sessionId: string | undefined = resumeContext.sessionId; let persistQueue: Promise< void > = Promise.resolve(); if ( options.noSessionPersistence ) { @@ -111,8 +117,6 @@ export async function runCommand( void persist( ( recorder ) => recorder.recordToolProgress( message, timestamp ) ); } ); - let currentModel: AiModelId = DEFAULT_MODEL; - ui.onSiteSelected = ( site ) => { void persist( ( recorder ) => recorder.recordSiteSelected( { @@ -244,6 +248,14 @@ export async function runCommand( }]\n\n${ prompt }`; } + await persist( ( recorder ) => + recorder.recordSessionContext( { + provider: currentProvider, + model: currentModel, + cwd: currentWorkingDirectory, + } ) + ); + await persist( ( recorder ) => recorder.recordUserMessage( { text: prompt, @@ -256,6 +268,7 @@ export async function runCommand( prompt: enrichedPrompt, env, model: currentModel, + cwd: currentWorkingDirectory, resume: sessionId, onAskUser: ( questions ) => askUserAndPersistAnswers( questions ), } ); diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index 76abf85dd8..a44f9063dd 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -1,6 +1,8 @@ import { __ } from '@wordpress/i18n'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import yargs from 'yargs/yargs'; +import { startAiAgent } from 'cli/ai/agent'; +import { resolveAiEnvironment, resolveInitialAiProvider } from 'cli/ai/auth'; import { AiSessionRecorder } from 'cli/ai/sessions/recorder'; import { deleteAiSession, listAiSessions, loadAiSession } from 'cli/ai/sessions/store'; import { registerCommand as registerAiCommand } from 'cli/commands/ai'; @@ -10,17 +12,28 @@ import { registerCommand as registerAiSessionsResumeCommand } from 'cli/commands import { getAnthropicApiKey } from 'cli/lib/appdata'; import { StudioArgv } from 'cli/types'; -const { reportErrorMock } = vi.hoisted( () => ( { +const { reportErrorMock, waitForInputMock } = vi.hoisted( () => ( { reportErrorMock: vi.fn(), + waitForInputMock: vi.fn(), } ) ); vi.mock( 'cli/lib/appdata', () => ( { 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', () => ( { @@ -101,7 +114,7 @@ vi.mock( 'cli/ai/ui', () => ( { return {}; } async waitForInput() { - return '/exit'; + return waitForInputMock(); } }, getToolDetail: ( _name: string, input: Record< string, unknown > ) => @@ -114,6 +127,7 @@ vi.mock( 'cli/ai/sessions/recorder', () => { static open = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); async recordSdkMessage() {} async recordToolProgress() {} + async recordSessionContext() {} async recordSiteSelected() {} async recordUserMessage() {} async recordAgentQuestion() {} @@ -140,6 +154,7 @@ describe( 'CLI: studio ai sessions command', () => { beforeEach( () => { vi.clearAllMocks(); vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'test-api-key' ); + waitForInputMock.mockResolvedValue( '/exit' ); vi.spyOn( process, 'exit' ).mockImplementation( () => undefined as never ); } ); @@ -312,4 +327,62 @@ describe( 'CLI: studio ai sessions command', () => { expect( deleteAiSession ).not.toHaveBeenCalled(); expect( reportErrorMock ).toHaveBeenCalled(); } ); + + it( 'restores provider, model, cwd, 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', + cwd: '/tmp/resume-cwd', + }, + { + 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( startAiAgent ).toHaveBeenCalledWith( + expect.objectContaining( { + model: 'claude-opus-4-6', + cwd: '/tmp/resume-cwd', + 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 index 78bbafdda9..9f76e1e601 100644 --- a/apps/cli/commands/ai/tests/sessions.test.ts +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -44,6 +44,11 @@ describe( 'ai-sessions', () => { source: 'prompt', sitePath: '/tmp/my-wordpress-website', } ); + await recorder.recordSessionContext( { + provider: 'anthropic-api-key', + model: 'claude-sonnet-4-6', + cwd: '/tmp/my-wordpress-website', + } ); await recorder.recordSdkMessage( { type: 'assistant', message: { @@ -95,6 +100,12 @@ describe( 'ai-sessions', () => { type: 'assistant', }, } ); + expect( events.find( ( event ) => event.type === 'session.context' ) ).toMatchObject( { + type: 'session.context', + provider: 'anthropic-api-key', + model: 'claude-sonnet-4-6', + cwd: '/tmp/my-wordpress-website', + } ); expect( events.find( ( event ) => event.type === 'tool.progress' ) ).toMatchObject( { type: 'tool.progress', message: 'Starting process daemon…', From c64473429e4211f1aec4e5efcc5d30bb1e194961 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 13 Mar 2026 08:34:12 +0100 Subject: [PATCH 22/33] Extract sessions filename related methods to file-naming.ts --- apps/cli/ai/sessions/file-naming.ts | 37 +++++++++++++++++++++++++++++ apps/cli/ai/sessions/recorder.ts | 10 ++------ apps/cli/ai/sessions/summary.ts | 12 ++-------- 3 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 apps/cli/ai/sessions/file-naming.ts 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/recorder.ts b/apps/cli/ai/sessions/recorder.ts index 5743b2e0bb..80a1d37e09 100644 --- a/apps/cli/ai/sessions/recorder.ts +++ b/apps/cli/ai/sessions/recorder.ts @@ -1,19 +1,13 @@ 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 toSortableTimestampPrefix( date: Date ): string { - return date - .toISOString() - .replace( /:/g, '-' ) - .replace( /\.\d{3}Z$/, '' ); -} - function toIsoTimestamp( value?: Date ): string { return ( value ?? new Date() ).toISOString(); } @@ -34,7 +28,7 @@ export class AiSessionRecorder { const startedAt = options.startedAt ?? new Date(); const sessionId = crypto.randomUUID(); const directory = getAiSessionsDirectoryForDate( startedAt ); - const fileName = `${ toSortableTimestampPrefix( startedAt ) }-${ sessionId }.jsonl`; + const fileName = buildAiSessionFileName( startedAt, sessionId ); const filePath = path.join( directory, fileName ); await fs.mkdir( directory, { recursive: true } ); diff --git a/apps/cli/ai/sessions/summary.ts b/apps/cli/ai/sessions/summary.ts index 9a2ba8a643..f613e63e87 100644 --- a/apps/cli/ai/sessions/summary.ts +++ b/apps/cli/ai/sessions/summary.ts @@ -1,15 +1,7 @@ import fs from 'fs/promises'; -import path from 'path'; +import { extractAiSessionIdFromFilePath } from './file-naming'; import type { AiSessionEvent, AiSessionSummary } from './types'; -function getSessionIdFromPath( filePath: string ): string { - const fileName = path.basename( filePath, '.jsonl' ); - const uuidMatch = fileName.match( - /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i - ); - return uuidMatch?.[ 1 ] ?? fileName; -} - export async function readAiSessionSummaryFromEvents( filePath: string, events: AiSessionEvent[] @@ -21,7 +13,7 @@ export async function readAiSessionSummaryFromEvents( const linkedAgentSessionIds: string[] = []; let createdAt: string | undefined; let updatedAt: string | undefined; - let sessionId = getSessionIdFromPath( filePath ); + let sessionId = extractAiSessionIdFromFilePath( filePath ); let firstPrompt: string | undefined; let selectedSiteName: string | undefined; let endReason: 'error' | 'stopped' | undefined; From 6f77f78a5414964e4dabf08c45edf69e70cf6ff9 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 13 Mar 2026 08:40:39 +0100 Subject: [PATCH 23/33] Clarify ai sessions path helpers --- apps/cli/ai/sessions/paths.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/ai/sessions/paths.ts b/apps/cli/ai/sessions/paths.ts index da69fb1796..5570d19bfa 100644 --- a/apps/cli/ai/sessions/paths.ts +++ b/apps/cli/ai/sessions/paths.ts @@ -1,6 +1,7 @@ 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' ); } @@ -9,6 +10,7 @@ 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 ); From 7d4c2d32d3fb644f76976dc540e0e2db0975f897 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 13 Mar 2026 13:17:04 +0100 Subject: [PATCH 24/33] Restore _activeSiteData from _activeSite when not set --- apps/cli/ai/tests/ui.test.ts | 56 ++++++++++++++++++++++++++++++++++++ apps/cli/ai/ui.ts | 12 ++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 apps/cli/ai/tests/ui.test.ts 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 5a92980a58..ef2e917b02 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -1111,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 ); From ed5de4bfce8fdd2d744a972215a66eabecd4ed2b Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 13 Mar 2026 13:32:25 +0100 Subject: [PATCH 25/33] Ensure sessions are only recorded when persist is called --- apps/cli/commands/ai/index.ts | 20 ++++++++++++++++---- apps/cli/commands/ai/tests/ai.test.ts | 23 ++++++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index 4cf6949c3b..bdcbcfc617 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -74,7 +74,16 @@ export async function runCommand( if ( options.noSessionPersistence ) { ui.showInfo( 'Session persistence disabled (--no-session-persistence).' ); - } else { + } + + const ensureSessionRecorder = async (): Promise< AiSessionRecorder | undefined > => { + if ( didDisableSessionPersistence ) { + return undefined; + } + if ( sessionRecorder ) { + return sessionRecorder; + } + try { if ( options.resumeSession ) { sessionRecorder = await AiSessionRecorder.open( { @@ -89,16 +98,19 @@ export async function runCommand( didDisableSessionPersistence = true; ui.showError( `Session persistence disabled: ${ getErrorMessage( error ) }` ); } - } + + return sessionRecorder; + }; const persist = ( callback: ( recorder: AiSessionRecorder ) => Promise< void > ) => { persistQueue = persistQueue.then( async () => { - if ( ! sessionRecorder ) { + const recorder = await ensureSessionRecorder(); + if ( ! recorder ) { return; } try { - await callback( sessionRecorder ); + await callback( recorder ); } catch ( error ) { sessionRecorder = undefined; if ( ! didDisableSessionPersistence ) { diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index a44f9063dd..b26b1ab5c7 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -184,7 +184,15 @@ describe( 'CLI: studio ai sessions command', () => { return parser; } - it( 'records sessions by default when running studio ai', async () => { + 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 ); @@ -231,12 +239,7 @@ describe( 'CLI: studio ai sessions command', () => { await buildParser().parseAsync( [ 'ai', 'sessions', 'resume', 'latest' ] ); expect( loadAiSession ).toHaveBeenCalledWith( 'session-latest' ); - expect( ( AiSessionRecorder as typeof AiSessionRecorder ).open ).toHaveBeenCalledWith( - expect.objectContaining( { - sessionId: 'session-latest', - filePath: '/tmp/session-latest.jsonl', - } ) - ); + expect( ( AiSessionRecorder as typeof AiSessionRecorder ).open ).not.toHaveBeenCalled(); expect( process.exit ).toHaveBeenCalledWith( 0 ); } ); @@ -376,6 +379,12 @@ describe( 'CLI: studio ai sessions command', () => { 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', From 59acf9e7ed3f23344bfe6a37917e50a6b8823f5b Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Tue, 17 Mar 2026 11:09:17 +0100 Subject: [PATCH 26/33] Update AI sessions commands registration --- apps/cli/index.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) 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' ); From 2de5ec1fd7502723f30cd2d3d9273b79684c797d Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Tue, 17 Mar 2026 11:09:40 +0100 Subject: [PATCH 27/33] Use truncateToWidth and visibleWidth from pi-tui --- apps/cli/commands/ai/sessions/helpers.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/cli/commands/ai/sessions/helpers.ts b/apps/cli/commands/ai/sessions/helpers.ts index 5ef90b556e..b34678ec70 100644 --- a/apps/cli/commands/ai/sessions/helpers.ts +++ b/apps/cli/commands/ai/sessions/helpers.ts @@ -1,4 +1,5 @@ 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'; @@ -47,19 +48,14 @@ function toSingleLine( text: string ): string { } function truncateWithEllipsis( text: string, maxLength: number ): string { - if ( text.length <= maxLength ) { - return text; + if ( maxLength <= 0 ) { + return ''; } - - if ( maxLength <= 1 ) { + if ( maxLength === 1 ) { return '…'; } - return text.slice( 0, maxLength - 1 ) + '…'; -} - -function visibleWidth( text: string ): number { - return Array.from( text ).length; + return truncateToWidth( text, maxLength, '…' ); } function padEndVisible( text: string, width: number ): string { From 9fab1158331ecb075141a7a778c3dc08a252b790 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Tue, 17 Mar 2026 16:00:55 +0100 Subject: [PATCH 28/33] Stop persisting cwd in session context --- apps/cli/ai/agent.ts | 2 +- apps/cli/ai/sessions/context.ts | 6 +----- apps/cli/ai/sessions/recorder.ts | 2 -- apps/cli/ai/sessions/types.ts | 1 - apps/cli/commands/ai/index.ts | 3 --- apps/cli/commands/ai/tests/ai.test.ts | 4 +--- apps/cli/commands/ai/tests/sessions.test.ts | 2 -- 7 files changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/cli/ai/agent.ts b/apps/cli/ai/agent.ts index 591167c052..4f971e61d8 100644 --- a/apps/cli/ai/agent.ts +++ b/apps/cli/ai/agent.ts @@ -37,7 +37,7 @@ const pathApprovalSession = createPathApprovalSession(); * Caller can iterate messages with `for await` and call `interrupt()` to stop. */ export function startAiAgent( config: AiAgentConfig ): Query { - const { prompt, env, model = DEFAULT_MODEL, cwd, maxTurns = 50, resume, onAskUser } = config; + const { prompt, env, model = DEFAULT_MODEL, maxTurns = 50, resume, onAskUser } = config; const resolvedEnv = env ?? { ...( process.env as Record< string, string > ) }; return query( { diff --git a/apps/cli/ai/sessions/context.ts b/apps/cli/ai/sessions/context.ts index e95ef680b5..72f787d2a7 100644 --- a/apps/cli/ai/sessions/context.ts +++ b/apps/cli/ai/sessions/context.ts @@ -14,7 +14,6 @@ export interface ResumeSessionContext { sessionId?: string; provider?: AiProviderId; model?: AiModelId; - cwd?: string; } export function resolveResumeSessionContext( @@ -47,12 +46,9 @@ export function resolveResumeSessionContext( if ( ! context.model && isAiModelId( event.model ) ) { context.model = event.model; } - if ( ! context.cwd && event.cwd.trim().length > 0 ) { - context.cwd = event.cwd; - } } - if ( context.sessionId && context.provider && context.model && context.cwd ) { + if ( context.sessionId && context.provider && context.model ) { break; } } diff --git a/apps/cli/ai/sessions/recorder.ts b/apps/cli/ai/sessions/recorder.ts index 80a1d37e09..1054cd9844 100644 --- a/apps/cli/ai/sessions/recorder.ts +++ b/apps/cli/ai/sessions/recorder.ts @@ -73,14 +73,12 @@ export class AiSessionRecorder { async recordSessionContext( options: { provider: AiProviderId; model: AiModelId; - cwd: string; } ): Promise< void > { await this.appendEvent( { type: 'session.context', timestamp: toIsoTimestamp(), provider: options.provider, model: options.model, - cwd: options.cwd, } ); } diff --git a/apps/cli/ai/sessions/types.ts b/apps/cli/ai/sessions/types.ts index c68ee7b3fb..4b4f02276d 100644 --- a/apps/cli/ai/sessions/types.ts +++ b/apps/cli/ai/sessions/types.ts @@ -19,7 +19,6 @@ export type AiSessionEvent = timestamp: string; provider: string; model: string; - cwd: string; } | { type: 'site.selected'; diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index bdcbcfc617..deeb840ac4 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -61,7 +61,6 @@ export async function runCommand( let currentProvider: AiProviderId = resumeContext.provider ?? ( await resolveInitialAiProvider() ); let currentModel: AiModelId = resumeContext.model ?? DEFAULT_MODEL; - const currentWorkingDirectory = resumeContext.cwd ?? process.cwd(); ui.currentProvider = currentProvider; ui.currentModel = currentModel; ui.start(); @@ -264,7 +263,6 @@ export async function runCommand( recorder.recordSessionContext( { provider: currentProvider, model: currentModel, - cwd: currentWorkingDirectory, } ) ); @@ -280,7 +278,6 @@ export async function runCommand( prompt: enrichedPrompt, env, model: currentModel, - cwd: currentWorkingDirectory, resume: sessionId, onAskUser: ( questions ) => askUserAndPersistAnswers( questions ), } ); diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index b26b1ab5c7..c157b1627d 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -331,7 +331,7 @@ describe( 'CLI: studio ai sessions command', () => { expect( reportErrorMock ).toHaveBeenCalled(); } ); - it( 'restores provider, model, cwd, and resume session id from session events', async () => { + it( 'restores provider, model, and resume session id from session events', async () => { vi.mocked( resolveInitialAiProvider ).mockResolvedValue( 'wpcom' ); waitForInputMock.mockResolvedValueOnce( 'Continue the task' ).mockResolvedValueOnce( '/exit' ); @@ -360,7 +360,6 @@ describe( 'CLI: studio ai sessions command', () => { timestamp: '2026-03-11T11:00:00.000Z', provider: 'anthropic-api-key', model: 'claude-opus-4-6', - cwd: '/tmp/resume-cwd', }, { type: 'sdk.message', @@ -388,7 +387,6 @@ describe( 'CLI: studio ai sessions command', () => { expect( startAiAgent ).toHaveBeenCalledWith( expect.objectContaining( { model: 'claude-opus-4-6', - cwd: '/tmp/resume-cwd', resume: 'session-from-sdk-message', } ) ); diff --git a/apps/cli/commands/ai/tests/sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts index 9f76e1e601..ab82505621 100644 --- a/apps/cli/commands/ai/tests/sessions.test.ts +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -47,7 +47,6 @@ describe( 'ai-sessions', () => { await recorder.recordSessionContext( { provider: 'anthropic-api-key', model: 'claude-sonnet-4-6', - cwd: '/tmp/my-wordpress-website', } ); await recorder.recordSdkMessage( { type: 'assistant', @@ -104,7 +103,6 @@ describe( 'ai-sessions', () => { type: 'session.context', provider: 'anthropic-api-key', model: 'claude-sonnet-4-6', - cwd: '/tmp/my-wordpress-website', } ); expect( events.find( ( event ) => event.type === 'tool.progress' ) ).toMatchObject( { type: 'tool.progress', From f59b43a0f5ab38273c011c91620d45f118f2ad97 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Wed, 18 Mar 2026 10:45:11 +0100 Subject: [PATCH 29/33] Save remote and url to site.selected event --- apps/cli/ai/sessions/recorder.ts | 9 ++- apps/cli/ai/sessions/replay.ts | 2 + apps/cli/ai/sessions/types.ts | 2 + apps/cli/commands/ai/index.ts | 2 + apps/cli/commands/ai/tests/sessions.test.ts | 62 +++++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/cli/ai/sessions/recorder.ts b/apps/cli/ai/sessions/recorder.ts index 1054cd9844..b0aa422d2e 100644 --- a/apps/cli/ai/sessions/recorder.ts +++ b/apps/cli/ai/sessions/recorder.ts @@ -82,12 +82,19 @@ export class AiSessionRecorder { } ); } - async recordSiteSelected( site: { name: string; path: string } ): Promise< void > { + 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, } ); } diff --git a/apps/cli/ai/sessions/replay.ts b/apps/cli/ai/sessions/replay.ts index bc3772f680..e8a6751e5f 100644 --- a/apps/cli/ai/sessions/replay.ts +++ b/apps/cli/ai/sessions/replay.ts @@ -16,6 +16,8 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): name: event.siteName, path: event.sitePath, running: false, + remote: event.remote === true, + url: typeof event.url === 'string' ? event.url : undefined, }, { announce: false, emitEvent: false } ); diff --git a/apps/cli/ai/sessions/types.ts b/apps/cli/ai/sessions/types.ts index 4b4f02276d..5bd38f7e7a 100644 --- a/apps/cli/ai/sessions/types.ts +++ b/apps/cli/ai/sessions/types.ts @@ -25,6 +25,8 @@ export type AiSessionEvent = timestamp: string; siteName: string; sitePath: string; + remote?: boolean; + url?: string; } | { type: 'user.message'; diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index deeb840ac4..aff6c2fa78 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -133,6 +133,8 @@ export async function runCommand( recorder.recordSiteSelected( { name: site.name, path: site.path, + remote: site.remote, + url: site.url, } ) ); }; diff --git a/apps/cli/commands/ai/tests/sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts index ab82505621..77cb02e69e 100644 --- a/apps/cli/commands/ai/tests/sessions.test.ts +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -144,6 +144,29 @@ describe( 'ai-sessions', () => { ] ); } ); + 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'; @@ -456,4 +479,43 @@ describe( 'ai-sessions', () => { 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: false, emitEvent: false } + ); + } ); } ); From ffb4ad92aafe511c1f83522e1564b2960db78bc7 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 19 Mar 2026 16:49:42 +0100 Subject: [PATCH 30/33] Ensure selected model after /model command is persisted before sending next prompt --- apps/cli/commands/ai/index.ts | 17 ++++--- apps/cli/commands/ai/tests/ai.test.ts | 73 +++++++++++++++++++-------- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index aff6c2fa78..1b38bb4a6f 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -122,6 +122,15 @@ export async function runCommand( 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 ); @@ -261,12 +270,7 @@ export async function runCommand( }]\n\n${ prompt }`; } - await persist( ( recorder ) => - recorder.recordSessionContext( { - provider: currentProvider, - model: currentModel, - } ) - ); + await persistSessionContext(); await persist( ( recorder ) => recorder.recordUserMessage( { @@ -416,6 +420,7 @@ export async function runCommand( currentModel = newModel[ 0 ]; ui.currentModel = currentModel; ui.showInfo( `Switched to ${ AI_MODELS[ currentModel ] }` ); + await persistSessionContext(); } continue; } diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index c157b1627d..f649ffff45 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -1,7 +1,7 @@ import { __ } from '@wordpress/i18n'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import yargs from 'yargs/yargs'; -import { startAiAgent } from 'cli/ai/agent'; +import { AI_MODELS, DEFAULT_MODEL, startAiAgent } from 'cli/ai/agent'; import { resolveAiEnvironment, resolveInitialAiProvider } from 'cli/ai/auth'; import { AiSessionRecorder } from 'cli/ai/sessions/recorder'; import { deleteAiSession, listAiSessions, loadAiSession } from 'cli/ai/sessions/store'; @@ -12,19 +12,28 @@ import { registerCommand as registerAiSessionsResumeCommand } from 'cli/commands import { getAnthropicApiKey } from 'cli/lib/appdata'; import { StudioArgv } from 'cli/types'; -const { reportErrorMock, waitForInputMock } = vi.hoisted( () => ( { - reportErrorMock: vi.fn(), - waitForInputMock: vi.fn(), -} ) ); +const { askUserMock, recordSessionContextMock, reportErrorMock, waitForInputMock } = vi.hoisted( + () => ( { + askUserMock: vi.fn(), + recordSessionContextMock: vi.fn(), + reportErrorMock: vi.fn(), + waitForInputMock: vi.fn(), + } ) +); -vi.mock( 'cli/lib/appdata', () => ( { - getAnthropicApiKey: vi.fn(), - getAuthToken: vi.fn().mockResolvedValue( { - displayName: 'Test User', - email: 'test@example.com', - } ), - saveAnthropicApiKey: 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' ] ), @@ -62,7 +71,8 @@ vi.mock( 'cli/logger', () => ( { setProgressCallback: vi.fn(), } ) ); -vi.mock( 'cli/ai/agent', () => { +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 ]() { @@ -76,11 +86,7 @@ vi.mock( 'cli/ai/agent', () => { }; return { - AI_MODELS: { - 'claude-sonnet-4-6': 'Sonnet 4.6', - 'claude-opus-4-6': 'Opus 4.6', - }, - DEFAULT_MODEL: 'claude-sonnet-4-6', + ...actual, startAiAgent: vi.fn( () => emptyQuery ), }; } ); @@ -111,7 +117,7 @@ vi.mock( 'cli/ai/ui', () => ( { } showAgentQuestion() {} async askUser() { - return {}; + return askUserMock(); } async waitForInput() { return waitForInputMock(); @@ -127,7 +133,9 @@ vi.mock( 'cli/ai/sessions/recorder', () => { static open = vi.fn().mockResolvedValue( new MockAiSessionRecorder() ); async recordSdkMessage() {} async recordToolProgress() {} - async recordSessionContext() {} + async recordSessionContext( ...args: unknown[] ) { + return recordSessionContextMock( ...args ); + } async recordSiteSelected() {} async recordUserMessage() {} async recordAgentQuestion() {} @@ -154,6 +162,7 @@ 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 ); } ); @@ -198,6 +207,28 @@ describe( 'CLI: studio ai sessions command', () => { 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( 'disables session recording with --no-session-persistence', async () => { await buildParser().parseAsync( [ 'ai', '--no-session-persistence' ] ); From 19ff5a6dbd9cd80643c2391b7789aa11084c0dad Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Thu, 19 Mar 2026 17:46:30 +0100 Subject: [PATCH 31/33] Use site.selected as the canonical source of truth for site selection --- apps/cli/ai/sessions/replay.ts | 13 +------------ apps/cli/commands/ai/tests/sessions.test.ts | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/cli/ai/sessions/replay.ts b/apps/cli/ai/sessions/replay.ts index e8a6751e5f..a52de7a65e 100644 --- a/apps/cli/ai/sessions/replay.ts +++ b/apps/cli/ai/sessions/replay.ts @@ -1,4 +1,3 @@ -import path from 'path'; import { AiChatUI } from 'cli/ai/ui'; import type { AiSessionEvent } from './types'; @@ -19,7 +18,7 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): remote: event.remote === true, url: typeof event.url === 'string' ? event.url : undefined, }, - { announce: false, emitEvent: false } + { announce: true, emitEvent: false } ); continue; } @@ -34,16 +33,6 @@ export function replaySessionHistory( ui: AiChatUI, events: AiSessionEvent[] ): ui.endAgentTurn(); } - if ( event.sitePath && ( ! ui.activeSite || ui.activeSite.path !== event.sitePath ) ) { - ui.setActiveSite( - { - name: path.basename( event.sitePath ), - path: event.sitePath, - running: false, - }, - { announce: false, emitEvent: false } - ); - } ui.beginAgentTurn(); isTurnOpen = true; ui.addUserMessage( event.text ); diff --git a/apps/cli/commands/ai/tests/sessions.test.ts b/apps/cli/commands/ai/tests/sessions.test.ts index 77cb02e69e..6d324b1dc9 100644 --- a/apps/cli/commands/ai/tests/sessions.test.ts +++ b/apps/cli/commands/ai/tests/sessions.test.ts @@ -515,7 +515,7 @@ describe( 'ai-sessions', () => { remote: true, url: 'https://my-remote-site.wordpress.com', }, - { announce: false, emitEvent: false } + { announce: true, emitEvent: false } ); } ); } ); From 3a955d1bb66290c79cf9a40a815a7a4ee5151f81 Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 20 Mar 2026 17:04:51 +0100 Subject: [PATCH 32/33] Remove unused cwd type --- apps/cli/ai/agent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cli/ai/agent.ts b/apps/cli/ai/agent.ts index 4f971e61d8..f953cae088 100644 --- a/apps/cli/ai/agent.ts +++ b/apps/cli/ai/agent.ts @@ -16,7 +16,6 @@ export interface AiAgentConfig { prompt: string; env?: Record< string, string >; model?: AiModelId; - cwd?: string; maxTurns?: number; resume?: string; onAskUser?: ( questions: AskUserQuestion[] ) => Promise< Record< string, string > >; From 08a1f9ecbb1b2020e180615071cfe73b47f35c7c Mon Sep 17 00:00:00 2001 From: Julien Verneaut Date: Fri, 20 Mar 2026 17:05:03 +0100 Subject: [PATCH 33/33] Persist session context on provider switch --- apps/cli/commands/ai/index.ts | 1 + apps/cli/commands/ai/tests/ai.test.ts | 46 ++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/cli/commands/ai/index.ts b/apps/cli/commands/ai/index.ts index 1b38bb4a6f..8d4773f91b 100644 --- a/apps/cli/commands/ai/index.ts +++ b/apps/cli/commands/ai/index.ts @@ -173,6 +173,7 @@ export async function runCommand( ui.currentProvider = currentProvider; sessionId = undefined; await saveSelectedAiProvider( currentProvider ); + await persistSessionContext(); if ( announce ) { ui.showInfo( `Switched to ${ AI_PROVIDERS[ currentProvider ] }` ); } diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index f649ffff45..a93dead792 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -2,7 +2,12 @@ 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 } from 'cli/ai/auth'; +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'; @@ -103,6 +108,7 @@ vi.mock( 'cli/ai/ui', () => ( { showWelcome() {} showInfo() {} showError() {} + setStatusMessage() {} prepareForReplay() {} finishReplay() {} beginAgentTurn() {} @@ -158,6 +164,14 @@ 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(); @@ -229,6 +243,36 @@ describe( 'CLI: studio ai sessions command', () => { 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' ] );