Skip to content

Commit 7e7251e

Browse files
authored
Merge pull request #35235 from storybookjs/kasper/ai-agent-instance-selection
CLI: Prefer agent-matched Storybook instances
2 parents c89349e + 7c9fa4b commit 7e7251e

9 files changed

Lines changed: 370 additions & 14 deletions

File tree

code/core/src/cli/ai/mcp/registry.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@ describe('readRegistry', () => {
131131
await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([minimal]);
132132
});
133133

134+
it('accepts records with optional agent provenance', async () => {
135+
const agentRecord = { ...aliveRecord, agent: 'claude-preview' };
136+
vol.fromNestedJSON({ [REGISTRY_DIR]: { 'agent.json': JSON.stringify(agentRecord) } });
137+
138+
await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([agentRecord]);
139+
});
140+
134141
it('rejects out-of-range ports', async () => {
135142
vol.fromNestedJSON({
136143
[REGISTRY_DIR]: {

code/core/src/cli/ai/mcp/resolve-instance.test.ts

Lines changed: 188 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,16 +186,201 @@ describe('resolveInstance', () => {
186186
});
187187

188188
it('selects the instance matching BOTH cwd and port when a port is supplied', () => {
189-
const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 });
190-
const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 });
191-
const result = resolveInstance([a, b], '/Users/x/projects/foo', 6007);
189+
const a = record('/Users/x/projects/foo', 'ready', {
190+
agent: 'claude-preview',
191+
pid: 100,
192+
port: 6006,
193+
});
194+
const b = record('/Users/x/projects/foo', 'ready', { agent: 'codex', pid: 200, port: 6007 });
195+
const result = resolveInstance([a, b], '/Users/x/projects/foo', 6007, 'claude');
192196
expect(result.kind).toBe('instance');
193197
if (result.kind === 'instance') {
194198
expect(result.record).toBe(b);
195199
expect(result.matches).toEqual([b]);
196200
}
197201
});
198202

203+
it('prefers Claude preview records for Claude CLI invocations', () => {
204+
const genericClaude = record('/Users/x/projects/foo', 'ready', {
205+
agent: 'claude',
206+
startedAt: '2026-06-09T11:00:00.000Z',
207+
});
208+
const claudePreview = record('/Users/x/projects/foo', 'ready', {
209+
agent: 'claude-preview',
210+
startedAt: '2026-06-09T10:00:00.000Z',
211+
});
212+
const newerCodex = record('/Users/x/projects/foo', 'ready', {
213+
agent: 'codex',
214+
startedAt: '2026-06-09T12:00:00.000Z',
215+
});
216+
const result = resolveInstance(
217+
[genericClaude, claudePreview, newerCodex],
218+
'/Users/x/projects/foo',
219+
undefined,
220+
'claude'
221+
);
222+
223+
expect(result.kind).toBe('instance');
224+
if (result.kind === 'instance') {
225+
expect(result.record).toBe(claudePreview);
226+
expect(result.matches).toEqual([claudePreview]);
227+
}
228+
});
229+
230+
it('falls back to generic Claude records when no Claude preview record matches', () => {
231+
const genericClaude = record('/Users/x/projects/foo', 'ready', {
232+
agent: 'claude',
233+
startedAt: '2026-06-09T10:00:00.000Z',
234+
});
235+
const newerCodex = record('/Users/x/projects/foo', 'ready', {
236+
agent: 'codex',
237+
startedAt: '2026-06-09T11:00:00.000Z',
238+
});
239+
const result = resolveInstance(
240+
[genericClaude, newerCodex],
241+
'/Users/x/projects/foo',
242+
undefined,
243+
'claude'
244+
);
245+
246+
expect(result.kind).toBe('instance');
247+
if (result.kind === 'instance') {
248+
expect(result.record).toBe(genericClaude);
249+
expect(result.matches).toEqual([genericClaude]);
250+
}
251+
});
252+
253+
it('stays in the preferred agent bucket even when another bucket is ready', () => {
254+
const startingPreview = record('/Users/x/projects/foo', 'starting', {
255+
agent: 'claude-preview',
256+
startedAt: '2026-06-09T11:00:00.000Z',
257+
});
258+
const readyCodex = record('/Users/x/projects/foo', 'ready', {
259+
agent: 'codex',
260+
startedAt: '2026-06-09T10:00:00.000Z',
261+
});
262+
const result = resolveInstance(
263+
[startingPreview, readyCodex],
264+
'/Users/x/projects/foo',
265+
undefined,
266+
'claude'
267+
);
268+
269+
expect(result.kind).toBe('intercept');
270+
if (result.kind === 'intercept') {
271+
expect(result.reason).toBe('mcp-starting');
272+
expect(result.matches).toEqual([startingPreview]);
273+
}
274+
});
275+
276+
it('reports errors from the preferred agent bucket instead of switching buckets', () => {
277+
const erroredPreview = record('/Users/x/projects/foo', 'error', {
278+
agent: 'claude-preview',
279+
startedAt: '2026-06-09T11:00:00.000Z',
280+
});
281+
const readyClaude = record('/Users/x/projects/foo', 'ready', {
282+
agent: 'claude',
283+
startedAt: '2026-06-09T10:00:00.000Z',
284+
});
285+
const result = resolveInstance(
286+
[erroredPreview, readyClaude],
287+
'/Users/x/projects/foo',
288+
undefined,
289+
'claude'
290+
);
291+
292+
expect(result.kind).toBe('intercept');
293+
if (result.kind === 'intercept') {
294+
expect(result.reason).toBe('mcp-error');
295+
expect(result.matches).toEqual([erroredPreview]);
296+
}
297+
});
298+
299+
it('prefers records matching the current non-Claude agent', () => {
300+
const codex = record('/Users/x/projects/foo', 'ready', {
301+
agent: 'codex',
302+
startedAt: '2026-06-09T10:00:00.000Z',
303+
});
304+
const newerCursor = record('/Users/x/projects/foo', 'ready', {
305+
agent: 'cursor',
306+
startedAt: '2026-06-09T11:00:00.000Z',
307+
});
308+
const result = resolveInstance(
309+
[codex, newerCursor],
310+
'/Users/x/projects/foo',
311+
undefined,
312+
'codex'
313+
);
314+
315+
expect(result.kind).toBe('instance');
316+
if (result.kind === 'instance') {
317+
expect(result.record).toBe(codex);
318+
expect(result.matches).toEqual([codex]);
319+
}
320+
});
321+
322+
it('chooses the latest-started ready instance inside the selected agent bucket', () => {
323+
const olderPreview = record('/Users/x/projects/foo', 'ready', {
324+
agent: 'claude-preview',
325+
startedAt: '2026-06-09T10:00:00.000Z',
326+
});
327+
const newerPreview = record('/Users/x/projects/foo', 'ready', {
328+
agent: 'claude-preview',
329+
startedAt: '2026-06-09T11:00:00.000Z',
330+
});
331+
const newestCodex = record('/Users/x/projects/foo', 'ready', {
332+
agent: 'codex',
333+
startedAt: '2026-06-09T12:00:00.000Z',
334+
});
335+
const result = resolveInstance(
336+
[olderPreview, newerPreview, newestCodex],
337+
'/Users/x/projects/foo',
338+
undefined,
339+
'claude'
340+
);
341+
342+
expect(result.kind).toBe('instance');
343+
if (result.kind === 'instance') {
344+
expect(result.record).toBe(newerPreview);
345+
expect(result.matches).toEqual([newerPreview, olderPreview]);
346+
}
347+
});
348+
349+
it('falls back to latest-started behavior when no record matches the current agent', () => {
350+
const older = record('/Users/x/projects/foo', 'ready', {
351+
startedAt: '2026-06-09T10:00:00.000Z',
352+
});
353+
const newer = record('/Users/x/projects/foo', 'ready', {
354+
agent: 'cursor',
355+
startedAt: '2026-06-09T11:00:00.000Z',
356+
});
357+
const result = resolveInstance([older, newer], '/Users/x/projects/foo', undefined, 'codex');
358+
359+
expect(result.kind).toBe('instance');
360+
if (result.kind === 'instance') {
361+
expect(result.record).toBe(newer);
362+
expect(result.matches).toEqual([newer, older]);
363+
}
364+
});
365+
366+
it('falls back to latest-started behavior when no current agent is detected', () => {
367+
const olderPreview = record('/Users/x/projects/foo', 'ready', {
368+
agent: 'claude-preview',
369+
startedAt: '2026-06-09T10:00:00.000Z',
370+
});
371+
const newerCodex = record('/Users/x/projects/foo', 'ready', {
372+
agent: 'codex',
373+
startedAt: '2026-06-09T11:00:00.000Z',
374+
});
375+
const result = resolveInstance([olderPreview, newerCodex], '/Users/x/projects/foo');
376+
377+
expect(result.kind).toBe('instance');
378+
if (result.kind === 'instance') {
379+
expect(result.record).toBe(newerCodex);
380+
expect(result.matches).toEqual([newerCodex, olderPreview]);
381+
}
382+
});
383+
199384
it('ignores port when it is not supplied (routes by cwd alone)', () => {
200385
const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 });
201386
const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 });

code/core/src/cli/ai/mcp/resolve-instance.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { resolve } from 'node:path';
22

3+
import {
4+
CLAUDE_AGENT_NAME,
5+
CLAUDE_PREVIEW_AGENT_NAME,
6+
} from '../../../shared/constants/agent-provenance.ts';
37
import type { InterceptReason, StorybookInstanceRecord } from './types.ts';
48

59
export type ResolveResult =
@@ -34,15 +38,16 @@ export type ResolveResult =
3438
* - `error` → mcp-error intercept
3539
*
3640
* Zero matches → no-instance intercept (callers may surface running cwds). 2+ matches at the same
37-
* cwd → pick the most recently started instance (latest `startedAt` among `ready` records, else
38-
* latest overall), on the assumption that the freshest instance is the one the agent just started.
39-
* Records without a `startedAt` tie-break on lowest pid for determinism. All matches are returned
40-
* (most-recent first) as `matches` so callers can warn the agent without blocking the call.
41+
* cwd → use the current agent to select the competing bucket, then pick the most recently started
42+
* instance in that bucket (latest `startedAt` among `ready` records, else latest overall). Records
43+
* without a `startedAt` tie-break on lowest pid for determinism. The selected bucket is returned
44+
* (most-recent first) as `matches` so callers can warn only about instances that competed.
4145
*/
4246
export function resolveInstance(
4347
records: StorybookInstanceRecord[],
4448
targetCwd: string,
45-
targetPort?: number
49+
targetPort?: number,
50+
currentAgent?: string
4651
): ResolveResult {
4752
const normalisedTarget = resolve(targetCwd);
4853
const cwdMatches = records.filter((r) => resolve(r.cwd) === normalisedTarget);
@@ -67,7 +72,7 @@ export function resolveInstance(
6772
};
6873
}
6974

70-
const sortedMatches = [...matches].sort(byMostRecentlyStarted);
75+
const sortedMatches = selectCompetingBucket(matches, targetPort, currentAgent);
7176
const selected = sortedMatches.find((r) => r.mcp.status === 'ready') ?? sortedMatches[0];
7277

7378
switch (selected.mcp.status) {
@@ -106,6 +111,28 @@ export function resolveInstance(
106111
}
107112
}
108113

114+
function selectCompetingBucket(
115+
matches: StorybookInstanceRecord[],
116+
targetPort: number | undefined,
117+
currentAgent: string | undefined
118+
) {
119+
if (targetPort != null) {
120+
return [...matches].sort(byMostRecentlyStarted);
121+
}
122+
123+
// std-env reports Claude CLI as `claude`; preview-launched Storybooks record `claude-preview`.
124+
const agentBuckets =
125+
currentAgent === CLAUDE_AGENT_NAME
126+
? [CLAUDE_PREVIEW_AGENT_NAME, CLAUDE_AGENT_NAME]
127+
: currentAgent
128+
? [currentAgent]
129+
: [];
130+
const selectedAgent = agentBuckets.find((agent) => matches.some((r) => r.agent === agent));
131+
const bucket = selectedAgent ? matches.filter((r) => r.agent === selectedAgent) : matches;
132+
133+
return [...bucket].sort(byMostRecentlyStarted);
134+
}
135+
109136
/**
110137
* `startedAt` as epoch millis, or `-Infinity` when absent/unparseable so such records sort as the
111138
* oldest (and fall through to the pid tie-break).

code/core/src/cli/ai/mcp/run-tool.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import { McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts';
44
import { loadStorybookAiMetadata, type StorybookAiMetadata } from './local-metadata.ts';
@@ -39,6 +39,10 @@ beforeEach(() => {
3939
vi.mocked(loadStorybookAiMetadata).mockReset().mockResolvedValue(defaultRuntimeMetadata);
4040
});
4141

42+
afterEach(() => {
43+
vi.unstubAllEnvs();
44+
});
45+
4246
describe('runAiTool', () => {
4347
it('forwards the call to the matching instance and prints the markdown result', async () => {
4448
const result = await runAiTool('list-all-documentation', ['--withStoryIds', 'true'], {
@@ -459,12 +463,65 @@ describe('runAiTool', () => {
459463
vi.mocked(readRegistry).mockResolvedValue([record, sibling]);
460464
const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' });
461465
expect(result.exitCode).toBe(0);
462-
expect(result.output).toContain('Multiple Storybook instances');
466+
expect(result.output).toContain('Multiple matching Storybook instances');
467+
expect(result.output).toContain('Matching instances at `/projects/foo`');
463468
expect(result.output).toContain('pid `1`');
464469
expect(result.output).toContain('pid `2`');
465470
expect(result.output).toContain('(used)');
466471
expect(result.output).toContain('upstream result');
467472
});
473+
474+
describe('when multiple instances exist in the selected agent bucket', () => {
475+
const olderPreview = {
476+
...record,
477+
agent: 'claude-preview',
478+
instanceId: 'inst-2',
479+
pid: 2,
480+
port: 6007,
481+
startedAt: '2026-06-09T10:00:00.000Z',
482+
url: 'http://localhost:6007',
483+
};
484+
const selectedPreview = {
485+
...record,
486+
agent: 'claude-preview',
487+
instanceId: 'inst-3',
488+
pid: 3,
489+
port: 6008,
490+
startedAt: '2026-06-09T11:00:00.000Z',
491+
url: 'http://localhost:6008',
492+
};
493+
const newerCodex = {
494+
...record,
495+
agent: 'codex',
496+
instanceId: 'inst-4',
497+
pid: 4,
498+
port: 6009,
499+
startedAt: '2026-06-09T12:00:00.000Z',
500+
url: 'http://localhost:6009',
501+
};
502+
503+
beforeEach(() => {
504+
vi.stubEnv('AI_AGENT', 'claude');
505+
vi.mocked(readRegistry).mockResolvedValue([olderPreview, selectedPreview, newerCodex]);
506+
});
507+
508+
it('only warns about instances in the selected agent bucket', async () => {
509+
const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' });
510+
511+
expect(callMcpTool).toHaveBeenCalledWith(
512+
selectedPreview,
513+
{ name: 'list-all-documentation', arguments: {} },
514+
undefined
515+
);
516+
expect(result.exitCode).toBe(0);
517+
expect(result.output).toContain('Multiple matching Storybook instances');
518+
expect(result.output).toContain('Matching instances at `/projects/foo`');
519+
expect(result.output).toContain('pid `2`');
520+
expect(result.output).toContain('pid `3`');
521+
expect(result.output).not.toContain('pid `4`');
522+
expect(result.output).not.toContain('http://localhost:6009');
523+
});
524+
});
468525
});
469526

470527
describe('buildStorybookCommandsHelp', () => {

0 commit comments

Comments
 (0)