Skip to content
1 change: 1 addition & 0 deletions apps/cli/ai/sessions/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function replaySessionHistory( ui: AiChatUI, entries: SessionEntry[] ): v
{
name: data.siteName,
path: data.sitePath,
// Placeholder — turn dispatch resolves the live state before each prompt.
running: false,
remote: data.remote === true,
url: data.url,
Expand Down
12 changes: 10 additions & 2 deletions apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { AiChatUI } from 'cli/ai/ui';
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { findSiteByFolder } from 'cli/lib/cli-config/sites';
import { isSiteRunning } from 'cli/lib/site-utils';
import { maybeShowTosNotice } from 'cli/lib/tos-notice';
import { Logger, LoggerError, setProgressCallback } from 'cli/logger';
import { StudioArgv } from 'cli/types';
Expand Down Expand Up @@ -98,7 +99,6 @@ export async function runCommand( options: {
activeSite?: {
name: string;
path: string;
running?: boolean;
remote?: boolean;
url?: string;
wpcomSiteId?: number;
Expand All @@ -116,7 +116,8 @@ export async function runCommand( options: {
ui.activeSite = {
name: options.activeSite.name,
path: options.activeSite.path,
running: options.activeSite.running ?? false,
// Placeholder — turn dispatch resolves the live state before each prompt.
running: false,
remote: options.activeSite.remote,
url: options.activeSite.url,
wpcomSiteId: options.activeSite.wpcomSiteId,
Expand Down Expand Up @@ -475,6 +476,13 @@ export async function runCommand( options: {
// Remote (WordPress.com) sites only have a URL and site ID; local sites have a filesystem path and running state.
let enrichedPrompt = prompt;
const site = ui.activeSite;
// The stored running flag can be absent or stale — the session event log
// carries no running state, replay hardcodes false, and the site can be
// started/stopped mid-session — so ask the daemon before every turn.
if ( site && ! site.remote ) {
const siteData = await findSiteByFolder( site.path );
site.running = siteData ? await isSiteRunning( siteData ) : false;
}
if ( site?.remote && site?.url ) {
enrichedPrompt = `[Active site: "${ site.name }" (ID: ${ site.wpcomSiteId }) at ${ site.url } (WordPress.com)]\n\n${ prompt }`;
} else if ( site ) {
Expand Down
84 changes: 84 additions & 0 deletions apps/cli/commands/ai/sessions/tests/resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { loadAiSession } from '@studio/common/ai/sessions/store';
import { vi, type Mock } from 'vitest';
import { runCommand as runAiCommand } from 'cli/commands/ai';
import { runCommand } from '../resume';

vi.mock( '@studio/common/ai/sessions/store', () => ( {
loadAiSession: vi.fn(),
listAiSessions: vi.fn(),
} ) );
vi.mock( 'cli/ai/sessions/paths', () => ( { getAiSessionsRootDirectory: vi.fn() } ) );
vi.mock( 'cli/ai/ui', () => ( { AiChatUI: class AiChatUI {} } ) );
vi.mock( 'cli/commands/ai', () => ( { runCommand: vi.fn() } ) );
vi.mock( 'cli/commands/ai/sessions/helpers', () => ( { chooseSessionForAction: vi.fn() } ) );

function siteSelectedEntry( data: Record< string, unknown > ) {
return {
type: 'custom',
customType: 'studio.site_selected',
id: 'e1',
parentId: null,
timestamp: '2024-01-01T00:00:00Z',
data,
};
}

describe( 'sessions resume — active site hydration (JSON mode)', () => {
beforeEach( () => {
vi.clearAllMocks();
} );

it( 'passes the local site resolved from the event log through to the ai command', async () => {
( loadAiSession as Mock ).mockResolvedValue( {
entries: [ siteSelectedEntry( { siteName: 'My Site', sitePath: '/sites/my-site' } ) ],
} );

await runCommand( 'session-1', { json: true, message: 'hello' } );

expect( runAiCommand ).toHaveBeenCalledWith(
expect.objectContaining( {
activeSite: expect.objectContaining( {
name: 'My Site',
path: '/sites/my-site',
remote: false,
} ),
} )
);
} );

it( 'passes remote sites through with their WordPress.com identity', async () => {
( loadAiSession as Mock ).mockResolvedValue( {
entries: [
siteSelectedEntry( {
siteName: 'Live',
sitePath: '/sites/live',
remote: true,
url: 'https://example.wordpress.com',
wpcomSiteId: 123,
} ),
],
} );

await runCommand( 'session-1', { json: true, message: 'hello' } );

expect( runAiCommand ).toHaveBeenCalledWith(
expect.objectContaining( {
activeSite: expect.objectContaining( {
remote: true,
url: 'https://example.wordpress.com',
wpcomSiteId: 123,
} ),
} )
);
} );

it( 'passes no active site when the session has no site_selected entry', async () => {
( loadAiSession as Mock ).mockResolvedValue( { entries: [] } );

await runCommand( 'session-1', { json: true, message: 'hello' } );

expect( runAiCommand ).toHaveBeenCalledWith(
expect.objectContaining( { activeSite: undefined } )
);
} );
} );
90 changes: 90 additions & 0 deletions apps/cli/commands/ai/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
openStudioSession,
} from 'cli/ai/sessions/pi-session';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { findSiteByFolder } from 'cli/lib/cli-config/sites';
import { isSiteRunning } from 'cli/lib/site-utils';
import { runCommand } from '../index';

vi.mock( '@studio/common/lib/shared-config', () => ( {
Expand Down Expand Up @@ -36,6 +38,9 @@ vi.mock( 'cli/lib/cli-config/core', () => ( {
vi.mock( 'cli/lib/cli-config/sites', () => ( {
findSiteByFolder: vi.fn(),
} ) );
vi.mock( 'cli/lib/site-utils', () => ( {
isSiteRunning: vi.fn(),
} ) );
vi.mock( 'cli/ai/sessions/context', () => ( {
resolveResumeSessionContext: () => ( { provider: undefined, model: undefined } ),
} ) );
Expand Down Expand Up @@ -153,3 +158,88 @@ describe( 'AI runCommand — resume by id restores session model', () => {
expect( callArgs.model ).toBe( 'claude-opus-4-8' );
} );
} );

describe( 'AI runCommand — active site banner running state', () => {
let stdoutSpy: ReturnType< typeof vi.spyOn >;

const dispatchedPrompt = () =>
( ( runStudioAgentTurn as Mock ).mock.calls[ 0 ][ 0 ] as { prompt: string } ).prompt;

beforeEach( () => {
vi.clearAllMocks();
( createStudioSession as Mock ).mockResolvedValue( {
appendCustomEntry: vi.fn( () => 'entry-id' ),
getSessionId: () => 'session-id',
getEntries: () => [],
} );
( readAuthToken as Mock ).mockResolvedValue( null );
( readCliConfig as Mock ).mockResolvedValue( { aiProvider: 'wpcom' } );
( resolveInitialAiProvider as Mock ).mockResolvedValue( 'wpcom' );
stdoutSpy = vi.spyOn( process.stdout, 'write' ).mockImplementation( () => true );
} );

afterEach( () => {
stdoutSpy.mockRestore();
} );

it( 'reports the site as running when the daemon says it is', async () => {
( findSiteByFolder as Mock ).mockResolvedValue( { id: 'site-1', path: '/sites/my-site' } );
( isSiteRunning as Mock ).mockResolvedValue( true );

await runCommand( {
adapter: new JsonAdapter(),
initialMessage: 'hello',
activeSite: { name: 'My Site', path: '/sites/my-site' },
} );

expect( dispatchedPrompt() ).toContain(
'[Active site: "My Site" at /sites/my-site (running)]'
);
} );

it( 'reports the site as stopped when the daemon says it is not running', async () => {
( findSiteByFolder as Mock ).mockResolvedValue( { id: 'site-1', path: '/sites/my-site' } );
( isSiteRunning as Mock ).mockResolvedValue( false );

await runCommand( {
adapter: new JsonAdapter(),
initialMessage: 'hello',
activeSite: { name: 'My Site', path: '/sites/my-site' },
} );

expect( dispatchedPrompt() ).toContain( '(stopped)' );
} );

it( 'treats a site missing from the CLI config as stopped', async () => {
( findSiteByFolder as Mock ).mockResolvedValue( undefined );

await runCommand( {
adapter: new JsonAdapter(),
initialMessage: 'hello',
activeSite: { name: 'Gone', path: '/sites/gone' },
} );

expect( isSiteRunning ).not.toHaveBeenCalled();
expect( dispatchedPrompt() ).toContain( '(stopped)' );
} );

it( 'skips the daemon check for remote sites', async () => {
await runCommand( {
adapter: new JsonAdapter(),
initialMessage: 'hello',
activeSite: {
name: 'Live',
path: '',
remote: true,
url: 'https://example.wordpress.com',
wpcomSiteId: 123,
},
} );

expect( findSiteByFolder ).not.toHaveBeenCalled();
expect( isSiteRunning ).not.toHaveBeenCalled();
expect( dispatchedPrompt() ).toContain(
'[Active site: "Live" (ID: 123) at https://example.wordpress.com (WordPress.com)]'
);
} );
} );
Loading