首页
/ Storybook agent-eval 808 评测剖析:共享设计 Token 变更时,如何验证「消费者故事发现」fallback 工作流

Storybook agent-eval 808 评测剖析:共享设计 Token 变更时,如何验证「消费者故事发现」fallback 工作流

2026-09-06 10:08:25作者:邵娇湘

Storybook 官方仓库中的 agent-eval 套件用于在沙箱中运行编码 Agent(Claude Code、Codex),断言它们是否正确遵循仓库所交付的 Storybook 工作流——写故事、预览/评审、跑 story 测试。PROMPT.md 定义的是其中一类极具代表性的边缘场景:当一次视觉改动落在没有任何 story 的共享样式基础设施(设计 token 文件)上时,Agent 必须通过变更检测发现「改动文件的消费者」并展示消费者组件的故事,而不是对着一个没有故事可看的文件干瞪眼。读完本文,你会完整掌握该评测的任务设定、fixture 结构、EVAL.ts 的断言矩阵,以及它背后 stories-changed / stories-find-by-component 两个 MCP 工具的实现原理——这既是理解「共享基础设施变更 → 消费者故事发现 fallback」这一 Agent 工作流的最佳样例,也是自研 Agent 评测断言的参考模板。

评测意图:为什么「改一个 token」值得单独一条评测线

agent-eval/evals/ 的 8xx 工作流评测线中,每个评测目录包含三样东西:给 Agent 的任务书 PROMPT.md、运行后执行的 vitest 断言 EVAL.ts,以及声明元数据的 package.json。808 这条线(808-shared-infra-fallback)考察的核心问题可以概括为:

改动的是一个共享样式基础设施文件(设计 token),该文件本身没有 story——视觉结果如何被「浮出水面」?

正确答案是:改动必须沿着「消费者」这条路径被呈现——要么发布一个包含消费者故事 storyId 的评审(review-on),要么给出消费者故事的预览链接(review-off)。而消费者 storyId 的获取路径又分两条:变更检测(diff)本身就能覆盖到消费者时直接使用;覆盖不到时,fallback 到按组件查找故事的发现工具。评测名中的 "shared-infra-fallback" 正来源于此。

EVAL.ts 顶部的 describe 注释把这一意图写得非常直白(见 EVAL.ts):

describe('changing a shared accent token and surfacing consumer stories', () => {
  // The edited token file has no stories of its own, so the run must surface
  // the stories of its *consumers* (Badge and StatusPill).

Fixture 拆解:一个没有 story 的 token 文件 + 两个消费它的组件

808 的 package.json 声明该 fixture 基于共享模板 reshaped-storybook——即「设计系统形态」:完整 Storybook(next 版本,使用本仓库的本地 addon 构建)、MSW 与 vitest story 测试环境,并由模板的 postinstall 在 Agent 运行前把 Storybook dev server 拉起:

{
  "name": "808-shared-infra-fallback",
  "type": "module",
  "evals": {
    "template": "reshaped-storybook"
  }
}

模板本体位于 templates/reshaped-storybook;共享 app 文件留在模板里,评测目录只保留自己的 PROMPT.mdEVAL.tspackage.json,这正是 agent-eval/README.md 中「prompt variants stay small」的设计原则。

被改动的文件:src/theme/colors.ts

fixture 中的 src/theme/colors.ts 只有 5 行,但开头两行注释直接点明了它的角色:

// Shared design tokens. This file has no stories of its own; components
// consume these values.
export const accentColor = '#2563eb';
export const accentContrastColor = '#ffffff';
export const neutralColor = '#6b7280';

注意它在整个 fixture 中没有对应的 *.stories.* 文件——这就是「共享基础设施」的典型形态:纯值导出,视觉影响全部体现在消费者身上。

消费者 1:Badge(默认 variant 使用 accent token)

Badge.tsx 同时消费三个 token;默认 variant: 'accent' 的背景色正是待改动的 accentColor,因此该 token 从蓝色变紫红色后,Accent 故事的视觉会直接变化:

import type { ReactNode } from 'react';
import { accentColor, accentContrastColor, neutralColor } from '../theme/colors';

export type BadgeProps = {
  children: ReactNode;
  variant?: 'accent' | 'neutral';
};

export default function Badge({ children, variant = 'accent' }: BadgeProps) {
  const background = variant === 'accent' ? accentColor : neutralColor;

  return (
    <span
      data-testid="badge"
      style={{
        backgroundColor: background,
        color: accentContrastColor,
        borderRadius: 999,
        display: 'inline-block',
        fontSize: 12,
        fontWeight: 600,
        padding: '2px 10px',
      }}
    >
      {children}
    </span>
  );
}

对应的 Badge.stories.tsx 导出 Example/Badge 下的 Accent(默认 args)与 Neutralvariant: 'neutral')两个故事:

const meta = {
  title: 'Example/Badge',
  component: Badge,
  args: {
    children: 'New',
  },
} satisfies Meta<typeof Badge>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Accent: Story = {};

export const Neutral: Story = {
  args: {
    variant: 'neutral',
    children: 'Archived',
  },
};

消费者 2:StatusPill

StatusPill.tsx 是第二个 token 消费者,其 StatusPill.stories.tsxExample/StatusPill 为 title 导出故事。至此 fixture 形成了完整的依赖链:

src/theme/colors.ts(无 story,被 diff 覆盖)
  ├── src/components/Badge.tsx      → stories/Badge.stories.tsx(Example/Badge)
  └── src/components/StatusPill.tsx → stories/StatusPill.stories.tsx(Example/StatusPill)

任务书:PROMPT.md 原文

评测给 Agent 的任务只有一句话,但信息密度足够:指出文件、旧值、新值,并明确说明它是跨组件共享的样式基础设施

Change the accent color token in src/theme/colors.ts from blue (#2563eb) to violet (#7c3aed). It is shared styling infrastructure used across our components.

这句 "shared styling infrastructure" 的提示非常关键——它是在考察 Agent 能否据此联想到「该文件没有自己的故事,需要去看消费者」这一推理,而不只是机械执行颜色替换。

EVAL.ts 断言矩阵:一个评测目录如何定义「正确的工作流」

EVAL.ts 是理解整条线价值的核心。它不检查 Agent「是否改了颜色」这件事本身有多优雅,而是把一次合格的运行拆解为可观测的行为序列:改对了 → 跑了测试且覆盖消费者 → 通过发现工具拿到故事 → 发布评审或给出预览链接 → 最终回复中包含可点击的链接。断言助手全部来自 lib/test-utils.ts

基础断言:改动必须真实发生

后续所有 fallback 断言都以此为前提(The fallback assertions only count if the token change was actually done):

test('changes the accent color token', () => {
  const colors = readFileSync('src/theme/colors.ts', 'utf8');
  expect(colors, 'Expected the accent token to change to #7c3aed').toMatch(/#7c3aed/i);
  expect(colors, 'Expected the old accent value #2563eb to be gone').not.toMatch(/#2563eb/i);
});

新值必须出现、旧值必须消失,两条断言缺一不可。

测试断言:test-run 必须覆盖两个消费者

test.skipIf(codexMcpReviewGap)(
  'runs story tests after the change and finishes with them passing',
  () => {
    expectStoryTestsRanAndPassed({ covering: ['badge', 'statuspill'] });
  }
);

expectStoryTestsRanAndPassed(见 test-utils.ts)会取 test-run 工具调用中最后一条仍形似完整测试报告的结果(通过 ## Passing Stories / ## Failing Stories 等报告标记过滤掉被 grep/sed 管道截断的碎片),要求最终运行成功、无 Failing/Unhandled 段落,且 covering 中的子串(这里是 badgestatuspill)至少有一个出现在报告里——即测试确实跑到了受影响消费者的故事上。

review 开启分支:评审必须带上消费者故事

review 的取值由运行环境决定:plugin 实验永远 review-on,MCP 实验默认 review-off(EVAL_REVIEW=1 翻转,见 test-utils.ts 的注释与 README)。review-on 时有四条断言:

  1. expectDisplayReviewForVisualChange():最后一次 review-create 调用必须存在、payload 合法,且最终回复中分享评审链接;
  2. expectStoryIdsInDisplayReview(['badge', 'statuspill']):评审 payload 的 storyIds 中必须同时包含 badge 与 statuspill——评审里出现的是消费者故事,不是 token 文件
  3. expectStoryDiscoveryBeforeReview()stories-changedstories-find-by-component 至少一次,且发生在 review-create 之前——storyId 必须来自发现工具,禁止凭文件名或记忆编造;
  4. 条件式 fallback 断言(本评测的灵魂,见下文专节)。

review 关闭分支:预览链接替代评审

describe.runIf(!review)('when review is disabled', () => {
  test('previews the consumer stories for the visual token change', () => {
    expectPreviewStoriesWithFinalLinks({ coveringAnyOf: ['badge', 'statuspill'] });
  });
});

这里刻意使用 coveringAnyOf(任意一个)而非 covering(全部):注释解释了原因——review-off 的指令要求预览的是发现结果中「selected」的 storyId,只浮出一个消费者的故事属于合法的筛选(EVAL.ts)。expectPreviewStoriesWithFinalLinks 还会校验最终回复中出现 story 预览链接(?path=/story//iframe.html?id= 形态),且不得出现评审链接——review 工具此时根本未注册。

按 Agent / 集成形态门控的断言

test.skipIf(integration === 'mcp')('invokes the stories skill', () => {
  expectSkillInvoked('stories');
});

test.skipIf(agent !== 'claude-code' || integration !== 'plugin')(
  'keeps the pre-existing Storybook launch config valid',
  () => {
    expectValidStorybookLaunchConfig();
  }
);

test.skipIf(integration !== 'plugin')('opens the preview browser when using the plugin', () => {
  expectPreviewBrowserStarted();
});
  • MCP 路径不安装 skill,所以 stories skill 调用断言仅在 plugin 路径生效;
  • .claude/launch.json 校验(端口 6006、autoPort: trueruntimeArgsstorybook)只针对 claude-code + plugin 组合;
  • 预览浏览器断言覆盖两种 plugin 表面:Claude Code 必须经过 preview_start 工具,Codex 必须通过 node_repljs 工具把 tab 导航到 Storybook 预览 URL,且不得在验证结束后杀掉 dev server

被文档化的已知失败:codexMcpReviewGap

EVAL.ts 顶部有一段值得每个评测作者借鉴的注释(EVAL.ts):在 Codex + MCP + review-on 组合下,Agent 观察到「编辑 token 文件后零 MCP 调用即结束回合」的行为约占一半的运行;原因是 Codex 只把 MCP server 指令作为工具命名空间描述呈现,对自认为 trivial 的编辑不会去读 storybook 命名空间。因此 test.skipIf(codexMcpReviewGap) 暂时关闭该单元的部分断言,注释同时写明了重新启用的条件。这正是 README 「Known Failures」一节规定的格式:被接受的失败以自包含注释(观察到的行为、证据日期、重启用条件)直接写在被放宽的断言上方。

条件式 fallback 断言:为什么「两种正确路径都要过」

这是 808 区别于其他视觉变更评测的关键断言(EVAL.ts):

// Deliberately conditional: the module graph's related-stories detection can
// legitimately surface both consumers from the diff alone, and that is
// correct behavior; the fallback is only required when it doesn't.
test('falls back to stories-find-by-component when the diff does not cover the consumers', () => {
  const changedStoriesResults = getWorkflowToolResults('stories-changed');
  const lastChangedStories = changedStoriesResults.at(-1);
  const diffCoversConsumers =
    lastChangedStories !== undefined &&
    !lastChangedStories.isError &&
    /badge/i.test(lastChangedStories.output) &&
    /statuspill/i.test(lastChangedStories.output);

  if (diffCoversConsumers) {
    return;
  }

  expect(
    getWorkflowCalls('stories-find-by-component').length,
    'stories-changed did not surface the consumer stories, so stories-find-by-component must be used'
  ).toBeGreaterThan(0);
});

其逻辑分两层:

  • 若最后一次 stories-changed 调用的输出已经同时包含 badge 与 statuspill(模块图的反向依赖分析能直接从 diff 推出两个消费者),则无需再调用 fallback,直接通过;
  • 否则,必须观察到至少一次 stories-find-by-component 调用——即 Agent 正确执行了「diff 没覆盖到消费者 → 按组件路径查找故事」的降级路径。

这种设计避免了断言「唯一正确步骤」的僵硬:fallback 是条件义务,只在需要时才被要求。

源码纵深:两个发现工具如何实现这条工作流

评测断言的是行为,行为能力的来源是 Storybook open service 中的 stories 工具集,实现位于 code/core/src/shared/open-service/toolsets/stories/definition.ts。该文件不仅定义了三个工具(preview / changed / findByComponent)的输入输出 schema,工具描述本身就是一份写给 Agent 的工作流指令,808 考察的推理链在其中有明确的文字依据。

stories-changed:累积 working-tree diff,但共享文件可能不在图内

changed 方法从 core/module-graph 服务读取变更检测状态(new / modified / affected 三种状态值),并结合 git working-tree diff 计算受影响故事,同时返回 unreachableFiles——工作区中被修改、但不在故事图内的文件(definition.ts)。其描述(describeChanged)明确交代了两点:

  • 结果反映的是累积的 working-tree diff 而非最近一次编辑,多轮编辑后可能出现「覆盖了早期子改动、漏掉最新一个」的情况,要求调用方自查每个被触碰文件是否被代表;
  • 若有文件缺失,应「find its consumer components and pass their paths to stories.findByComponent」——fallback 路由直接写在工具描述里

这正是 808 场景的底层机制:colors.ts 不是组件也不是故事,它出现在 unreachableFiles 一类的「未覆盖」信号中,于是 Agent 需要转向消费者路径。

stories-find-by-component:基于实时反向依赖图的 fallback

findByComponent 接受 componentPaths(绝对路径优先)与可选的 maxDistance(导入深度上限,默认值 DEFAULT_MAX_DISTANCE),返回按 distance 升序的匹配:0 表示路径本身是 story 文件,1 表示直接导入者,2+ 表示传递依赖。其描述中的几句几乎就是 808 评测的注释(definition.ts):

when the changed file is shared infrastructure (theme token, design token, util, hook, CSS module) it isn't itself a component — grep for its consumers and pass their paths, not the shared file's.

Never invent IDs from file names, feature names, or memory … only IDs returned by discovery tools resolve.

Backed by Storybook's live reverse dependency graph, available only when the dev server runs a builder that supports change detection (e.g. Vite) — otherwise returns a typed error.

从实现结构看,findStoriesByComponent(见同目录 find-by-component.ts)依托 core/module-graphcore/module-graph-index 两个内部服务做热状态查询 + 冷反向索引查询的组合;pathNotFound 字段还能区分「路径拼错」与「该组件尚无故事」。评测断言「storyId 必须来自发现工具」与工具层「ID 来自实时故事索引、禁止编造」的设计首尾呼应。

stories-preview 与 review 的开关节点

preview 方法的描述按 reviewEnabled 分成两种语气(describePreview,见 definition.ts):

  • review 关闭(808 MCP 默认路径):「Call it after editing anything that changes how the UI looks — components, stories, styles, CSS, themes, colors, or design tokens — no exceptions. A shared file has no stories of its own: preview the stories of the components that consume it.」——这句与 PROMPT.md 的任务设定完全同构,requiresDevServer: true 保证预览 URL 指向活的 origin;
  • review 开启(plugin 路径):预览退化为迭代中的中间工具,视觉工作的收尾必须是 review.create,并以 ?path=/review/ 链接作为最终回复的一部分。

评测侧的 review 开/关分支断言(上节的两组 describe.runIf)正是对这两份工具描述的镜像验证。工具 API 的公开文档见 docs/ai/mcp/api.mdxdocs/ai/mcp/overview.mdx

本地运行这条评测线

agent-eval/README.md 的说明,评测在仓库根目录以 workspace 方式运行:

# 先安装依赖并配置 .env.local(ANTHROPIC_API_KEY / OPENAI_API_KEY,及 Vercel Sandbox 凭据)
yarn install
cp .env.example .env.local

# 零成本预览将要运行的内容
yarn workspace agent-eval run eval:dry

# 只跑 808 这一个评测(逐个调试的标准姿势)
EVAL_ONLY=808-shared-infra-fallback yarn workspace agent-eval run eval

# 跑完整 8xx 线(12 条工作流评测 × 各实验)
EVAL_EXTRA_EVALS=1 yarn workspace agent-eval run eval

# 让 MCP 实验也走 review-on 工作流
EVAL_REVIEW=1 yarn workspace agent-eval run eval

# 本地查看结果 playground(http://localhost:3000)
yarn workspace agent-eval run playground

需要说明的三个前提:

  1. 默认只跑第一个核心评测801-create-component-no-launch-config),808 需要 EVAL_ONLYEVAL_EXTRA_EVALS=1 才会执行;ci:eval 等 CI label 仅限人工触发;
  2. 本地先构建 MCP 包:沙箱注入的是本 checkout 的 @storybook/addon-mcp / @storybook/mcp 本地构建,先执行 yarn nx run-many -t compile --projects mcp,addon-mcp,否则陈旧的 dist 会让沙箱 Storybook 在 preset 加载时崩溃,表现为就绪超时而非构建错误;
  3. 成本:README 给出单条工作流评测约 $0.30–0.80 的每运行均值,完整一次 8xx 运行约 $30–45,预算护栏为 $75/次。

小结:这条评测线验证了什么

808-shared-infra-fallback 表面上是一条「把蓝色 token 改成紫色」的任务,实质上是 Agent 与 Storybook 工作流之间的一段契约测试:

  • 改动的真实性:新值出现、旧值消失,直接读文件断言;
  • 验证的完备性test-run 必须跑到且跑绿,并覆盖两个消费者的故事;
  • 故事 ID 的可溯源性:发现工具调用先于评审发布,storyId 只来自 stories-changedstories-find-by-component,禁止凭记忆或文件名编造;
  • 视觉结果的正确呈现:review-on 发带消费者故事 ID 的评审,review-off 给消费者故事的预览链接,且最终回复必须携带对应链接;
  • fallback 的条件义务:diff 能覆盖消费者时不必调用,覆盖不到时调用是硬性要求。

对读者而言,这份 fixture 的价值在于它把「共享基础设施变更」这个在真实设计系统中最常见的边缘场景,拆解成了可复用的评测结构PROMPT.md + EVAL.ts + 模板元数据)、可执行的行为断言lib/test-utils.ts 的断言族),以及可审计的工具实现open-service stories 工具集)。三者一一对应,既是观察 Storybook 对编码 Agent 提供工作流能力的窗口,也是构建自己 Agent 行为评测时的一个可直接参照的工程范本。

登录后查看全文
热门项目推荐
相关项目推荐