Storybook agent-eval 808 评测剖析:共享设计 Token 变更时,如何验证「消费者故事发现」fallback 工作流
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.md、EVAL.ts 与 package.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)与 Neutral(variant: '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.tsx 以 Example/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.tsfrom 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 中的子串(这里是 badge、statuspill)至少有一个出现在报告里——即测试确实跑到了受影响消费者的故事上。
review 开启分支:评审必须带上消费者故事
review 的取值由运行环境决定:plugin 实验永远 review-on,MCP 实验默认 review-off(EVAL_REVIEW=1 翻转,见 test-utils.ts 的注释与 README)。review-on 时有四条断言:
expectDisplayReviewForVisualChange():最后一次review-create调用必须存在、payload 合法,且最终回复中分享评审链接;expectStoryIdsInDisplayReview(['badge', 'statuspill']):评审 payload 的 storyIds 中必须同时包含 badge 与 statuspill——评审里出现的是消费者故事,不是 token 文件;expectStoryDiscoveryBeforeReview():stories-changed或stories-find-by-component至少一次,且发生在review-create之前——storyId 必须来自发现工具,禁止凭文件名或记忆编造;- 条件式 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,所以
storiesskill 调用断言仅在 plugin 路径生效; .claude/launch.json校验(端口 6006、autoPort: true、runtimeArgs含storybook)只针对 claude-code + plugin 组合;- 预览浏览器断言覆盖两种 plugin 表面:Claude Code 必须经过
preview_start工具,Codex 必须通过node_repl的js工具把 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-graph 与 core/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.mdx 与 docs/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
需要说明的三个前提:
- 默认只跑第一个核心评测(
801-create-component-no-launch-config),808 需要EVAL_ONLY或EVAL_EXTRA_EVALS=1才会执行;ci:eval等 CI label 仅限人工触发; - 本地先构建 MCP 包:沙箱注入的是本 checkout 的
@storybook/addon-mcp/@storybook/mcp本地构建,先执行yarn nx run-many -t compile --projects mcp,addon-mcp,否则陈旧的dist会让沙箱 Storybook 在 preset 加载时崩溃,表现为就绪超时而非构建错误; - 成本:README 给出单条工作流评测约 $0.30–0.80 的每运行均值,完整一次 8xx 运行约 $30–45,预算护栏为 $75/次。
小结:这条评测线验证了什么
808-shared-infra-fallback 表面上是一条「把蓝色 token 改成紫色」的任务,实质上是 Agent 与 Storybook 工作流之间的一段契约测试:
- 改动的真实性:新值出现、旧值消失,直接读文件断言;
- 验证的完备性:
test-run必须跑到且跑绿,并覆盖两个消费者的故事; - 故事 ID 的可溯源性:发现工具调用先于评审发布,storyId 只来自
stories-changed或stories-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 行为评测时的一个可直接参照的工程范本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00