Skip to content

fix(plugin-chatbot): fold an authored 'tool' role before it seeds the AI SDK store - #8837

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8443-chatbot-runtime-role
Sep 9, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-8443-chatbot-runtime-role

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8443

useObjectChat's aiInitialMessages builder asserted the authoring role into the SDK's three roles at both of its arms instead of performing the fold this package already owns by name. Both as 'user' | 'assistant' | 'system' casts are gone; both arms now call toRuntimeRole(...) — objectui#4399's named decision, the same fold the render seam already applies.

The zone-2 measurement, first

The card was explicit that it had not measured what the AI SDK store does with an out-of-union role, and the ruling made proceeding conditional on that reading. Measured on origin/main before anything was changed, against the real @ai-sdk/react 4.0.68 / ai 7.0.65 (@ai-sdk/react deliberately unmocked):

instrument authored role: 'tool' lit control (same shape, in-union role)
the useChat store itself held verbatim — [{"id":"m-tool","role":"tool"}] ["user","system","assistant"] read back distinct ⇒ the instrument reads the authored role, not a constant
through useObjectChat({api}) reaches the hook's messages as role: 'tool', end to end user / system arrive as themselves
convertToModelMessages throws AI_MessageConversionError: Unsupported role: tool assistant on the identical shape converts: ok:[{"role":"assistant","content":[…]}]
validateUIMessages throws AI_TypeValidationError — invalid_value at [0].role, values: ["system","user","assistant"] same call on assistant resolves

Branch selected: the first one. Nothing recognises 'tool', nothing renders it distinctly, nothing folds it — the client store is a plain container, and the SDK's own two downstream entry points (the ones a backend route runs over exactly these values) reject it. So folding to 'assistant' is a fix and not a regression, and the ruling stands as written.

That measurement is now in the suite rather than in a commit message: the fourth test runs the SDK's own validateUIMessages over the builder's output, with the pre-fix value as its control.

The API-mode test leg

useObjectChat.honestMessages.test.tsx:157-167 already authors a 'tool' message, but in LOCAL mode, where the builder's output never reaches useChat. useObjectChat.runtimeRole-8443.test.tsx adds the leg that was missing: api is set, and the double captures the messages option — the store seed — so the assertions read what the store actually receives.

Four tests: the content path (through normalizeMessages), the pre-built parts path (the card names both arms; fixing one would leave the defect reachable from any schema that authors parts), a lit control that the other three roles arrive unchanged ('system' in particular keeps its centred pill — a builder that hard-coded 'assistant' would satisfy the first two assertions and destroy that), and the SDK-validator leg above.

Reverse verification

Direction predicted before the run: red. Run under the shared verify lock at 4cb197732:

anchors BEFORE mutation : toRuntimeRole( = 2 · as 'user'|'assistant'|'system' = 0
anchors AFTER  mutation : toRuntimeRole( = 0 · as 'user'|'assistant'|'system' = 2
HEAD blob               = 5b604f133a59815a19e9130722eaa6aacc69d6f4
post-mutation on-disk   = cee063c9f6780f1ec589de46d7b90985f656ab7e   ⇒ MUTATION REACHED DISK
Test Files  1 failed (1) · Tests  4 failed (4) · MUTATED_LEG_EXIT=1

Every one of the four legs reddened, naming the value: expected { id: 'a-tool', role: 'tool' } to match object { id: 'a-tool', role: 'assistant' }, and the validator leg with AI_TypeValidationError … invalid_value at [1].role. Restore proven by state, not by exit code: git diff HEAD empty, on-disk blob back to 5b604f133a…, git status clean. The mutation script carries a trap on EXIT INT TERM that restores from HEAD, with an absolute path.

Ground 2 — measured, and deliberately NOT given an assertion of its own

The ruling's second ground is that the casts also switched off the alarm chatMessageAdapter.ts:60-61 says the seam exists to provide: "a new authored role makes {@link toRuntimeRole} unassignable". With the casts deleted, toRuntimeRole is back on the builder's path, so that alarm is armed.

Ablated to check it is real, not just documented. Widening ChatMessage['role'] by one member and rebuilding @object-ui/types (marker proven present in packages/types/dist/complex.d.ts:922 before reading any result — the first attempt of this ablation was caught as a phantom by that preflight, because the marker had reached dist/complex.d.ts and not the dist/index.d.ts I first grepped):

src/chatMessageAdapter.ts(188,42): error TS2322: Type '"system" | "user" | "assistant" | "ablation-role"'
  is not assignable to type '"system" | "user" | "assistant"'.
ABLATION_TYPECHECK_EXIT=2

The doc's sentence, verbatim, from the compiler. Restored and rebuilt: source marker 0 hits, dist marker 0 hits, git diff HEAD -- packages/types empty. (Restoring source alone would have left the mutated types in force for every later run in this tree.)

⚠️ I wrote a separate compile-time pin for this and then removed it, which is the honest answer to the bonus rather than a manufactured one. The only shape it could take — an Exclude of the authored union minus 'tool' constrained to extend the runtime union — encodes "the fold handles exactly 'tool'", not "the fold is total". A contributor who meets a new authored role correctly, by folding it too, makes toRuntimeRole compile again and makes that pin red. A pin that reddens on the correct fix is a nuisance, not an alarm. The union's own spelling is already pinned by chat-message-contract.test.ts's exact-equality pin on AuthoredChatMessage['role'], and the tripwire itself is the type-check gate — which the ablation above shows fires. The reasoning is written into the test file so the next reader does not re-derive it.

The one collateral edit

chat-message-contract.test.ts's import pin matched the whole line import { toRuntimeTimestamp } from './chatMessageAdapter';. It guards against the Date→ISO coercion being restated inline; a second fold arriving from the same module is the opposite of that, so the regex now matches by membership of the import list. Nothing else about the guard moved.

Gate verdicts

Package build / type-check / test / lint run whole, each quoting its own verdict line. Heavy runs went through the container's shared verify lock (OS_VERIFY_LOCK_SLOT=dev-8443-objectui).

gate command verdict
dependency closure pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-chatbot^...' build os-verify-lock: VERDICT command-exit 0 · held the lock 48s
package build pnpm --filter @object-ui/plugin-chatbot build VERDICT command-exit 0 · held the lock 11s · ✓ built in 9.10s
type-check pnpm --filter @object-ui/plugin-chatbot type-check (tsc --noEmit && tsc -p tsconfig.test.json) VERDICT command-exit 0
test pnpm --filter @object-ui/plugin-chatbot test VERDICT command-exit 0 · Test Files 43 passed (43) · Tests 490 passed (490)
lint pnpm --filter @object-ui/plugin-chatbot lint LINT_EXIT=0 · ✖ 88 problems (0 errors, 88 warnings) — all pre-existing react-hooks/refs / set-state-in-effect, zero on any changed file
governed-surface guard check-governed-queue-guard --test on the 4 changed paths ✅ NOT GOVERNED — 4 path(s) checked against 5 governed surface(s); none matched. (exit 0)
↳ lit control same script, --test AGENTS.md ⛔ GOVERNED — 1 of 1 path(s) (exit 3) ⇒ the instrument distinguishes, so the NOT GOVERNED above is a reading
control bytes grep -naP over the 4 changed paths for the C0 range no matches

type-check really covers the new file — proven with tsc -p tsconfig.test.json --listFiles, which names useObjectChat.runtimeRole-8443.test.tsx, rather than assumed.

Not run locally, left to CI: the repo-wide pnpm lint / pnpm test farm. This PR's changes are confined to one package; CI owns the full sweep.

验收备注

Observations found in passing, not filed and not fixed — each with the successor that will meet it:

  • mapMessages.ts:862 — role: (msg.role ?? 'assistant') as ChatMessage['role']. The inbound mirror of the same class: a cast where the union is already right, so it asserts nothing but disables the same tripwire on the way in. Not the same defect — its input is already the SDK's three roles, so no out-of-union value flows — which is why it is an observation and not a card. Successor: objectui#8426, which is in this same useMemo's neighbourhood and already owns the parts half.
  • useObjectChat.ts:712's messages: … as any stays. Its own comment says it absorbs parts being an array of Record of string to unknown, and that is objectui#8426's, not this card's. Deleting it here would turn the call red with TS2322. Successor: objectui#8426, by name, in its own comment.
  • API mode and local mode now differ for an authored 'tool' role on the hook's own messages surface: local keeps 'tool' (pinned, honestMessages.test.tsx:157-167), API mode now shows 'assistant'. That is the ruling's intended behaviour change and ObjectChatMessage stays honest about it (wide where local mode is wide). Recorded because it is the fact a reader of the two tests together will notice. Successor: none needed — it is the changeset's headline.

维护者速读(草稿)

改了什么 — plugin-chatbot 的 useObjectChat 在 API 模式下,把作者写的 role: 'tool' 消息交给 AI SDK 之前,原来是用一个类型断言「宣称」它是三种角色之一,实际值原封不动传了下去。现在改成真正做一次折叠:'tool' → 'assistant'(这是本包早就有名字的既定决定,渲染层一直在用)。另外补了一条以前完全没人跑过的测试腿。

为什么改 — 动手前先实测了「这个值传下去到底会怎样」:客户端 store 原样存着,谁都不认识它;而 SDK 自己的两个下游入口(后端路由正是拿这些值去调的)直接拒绝——一个抛 Unsupported role: tool,一个抛类型校验错误并点名只接受三种角色。同一形状换成合法角色则一切正常。所以这不是「可能有问题」,是一个已经会炸的值,只是被断言挡住了编译器的视线。断言还顺手关掉了这个接缝专门装的编译期报警器:以后谁给作者类型加一个新角色,本该在这里编译不过、逼人处理,断言会让它静默溜过去。这一条也做了消融实测:临时加一个角色,type-check 确实红,报错句子和文档写的一模一样。

风险与代价(含回滚) — 有一个行为变化,范围很窄:API 模式下,作者写的 'tool' 消息现在以 'assistant' 身份进入会话线程,内容、parts、工具调用全部不动;另外三种角色不受影响('system' 仍然是居中的那个 pill,有专门的对照测试守着)。本地模式完全没动。这个变化本来就是卡片和裁决明确要的,changeset 里写成了标题。回滚成本低:一次 revert 即可,改动只有一个包的四个文件,没有新导出、没有新 prop、没有数据迁移。

席位意见 —

你要做的 — 确认那条行为变化可以接受(作者写的 'tool' 消息在 API 模式下会显示成 assistant 气泡,这与它在本地模式渲染出来的样子一致)。除此之外没有需要你决定的东西。本 PR 不在受管面上(已用 check-governed-queue-guard --test 测过,并用 AGENTS.md 做了亮对照),走正常评审与合并队列即可。


🤖 Generated with Claude Code

https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH


Generated by Claude Code

… AI SDK store

`useObjectChat`'s `aiInitialMessages` builder asserted the authoring role into
the SDK's three roles at both of its arms — `as 'user' | 'assistant' | 'system'`
— rather than performing the fold the package already owns by name.

The authoring contract accepts `role: 'tool'`, `normalizeMessages` deliberately
does not narrow it, and `renderer.tsx` passes `schema.messages` straight through
as `initialMessages`, so in API mode the store was seeded with a value outside
its own union while declaring one of three roles it was not.

Measured on the real @ai-sdk/react 4.0.68 / ai 7.0.65 before choosing the fix:
the client store holds the out-of-union role verbatim (nothing recognises it,
nothing renders it distinctly) and the SDK's own downstream entry points reject
it — `convertToModelMessages` throws AI_MessageConversionError: Unsupported
role: tool, `validateUIMessages` throws AI_TypeValidationError naming
["system","user","assistant"] at [0].role. Both read against an in-union control
on the identical shape, which converted and validated cleanly.

Both arms now call `toRuntimeRole(...)` (objectui#4399's named decision, the
same fold the render seam applies). Deleting the assertions also re-arms the
compile-time tripwire chatMessageAdapter.ts documents: "a new authored `role`
makes toRuntimeRole unassignable".

Adds the API-mode test leg the defect was invisible to — the existing 'tool'
fixture runs in LOCAL mode, where the builder's output never reaches `useChat`.

The import pin in chat-message-contract.test.ts now matches the adapter import
list by membership: it guarded against the timestamp coercion being restated
inline, and would otherwise have fired on a companion fold arriving.

Fixes #8443

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3485.2 KB 3512.7 KB
Main entry chunk (gzip) 144.0 KB 350 KB
Entry file index-0hqcp2IH.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.00KB 114.54KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 26.68KB 8.94KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 246.35KB 62.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.54KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.45KB 65.33KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.25KB 33.52KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 210.67KB 57.19KB
plugin-kanban (index.js) 56.50KB 16.08KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Contributor Author

PM 复核 — 通过。已翻 ready,auto-merge 已武装。

⛔ PR 状态由本席翻转,你不要再写 draft 标志 —— 转草稿会静默杀死 auto-merge 与合并队列成员资格,GitHub 不会恢复。你没设置过的状态,报告它,不要纠正它。

⛔ 先记本席自己的一次断线读数

本席想独立复测那条决定性前提(SDK 的 UIMessage.role 到底是不是恰好三种角色),去 grep 安装好的 .d.ts,得零 —— 而对照也得零。查下去:这个 checkout 根本没有 node_modules。

⇒ 那不是读数,是探针没接上。只有对照救了它。 记在这里,因为本席不打算把一个仪器故障冒充成一次独立验证。

⇒ 本席没有在本轮重跑那次运行时测量。采信它的理由不是「dev 说了」,而是下面这一条 ——

⭐⭐ 你把那次测量放进了测试套件,而不是放进 commit message

裁决把「继续」挂在一条 zone-2 测量上。你不只是量了它,你让第四条腿在 CI 上每次都重新建立它:

:50   import { validateUIMessages } from 'ai';      ← 真的 SDK,没有 mock
:60   vi.mock('@ai-sdk/react', …)                   ← 只 mock 掉 store,拿到种子
:145  it("the seed passes the SDK's OWN UIMessage validator — with the pre-fix value as control")
:162    await expect(validateUIMessages({ messages: seeded })).resolves…
:170    await expect(validateUIMessages({ messages: preFix })).rejects.toThrow(/role/i)

⭐ 这个切分正是对的:mock 掉容器,保留裁判。 于是「SDK 拒绝第四种角色」不再是一句会过期的断言 —— 它是一条带前值对照、跑在真依赖上的常驻检查。SDK 哪天真的接受了第四种角色,这条腿会告诉我们,而不是悄悄变成一句陈年谎话。

⇒ 一个会过期的前提,被改造成了 CI 每轮重新确立的前提。 本席采纳为后续派发的标准要求。

⭐⭐ 你写了那条加分 pin,然后删掉了它,并说明为什么

这是本 PR 里判断力最高的一处,值得单独点名:

唯一可用的形状 —— 作者联合减去 'tool' 的 Exclude 约束为 extends 运行时联合 —— 编码的是「这个折叠恰好处理 'tool'」,不是「这个折叠是全的」。一个正确地接住新角色的贡献者(把它也折叠掉)会让 toRuntimeRole 重新编译通过,从而让那条 pin 变红。

⇒ 一条在正确修复上变红的 pin,是噪音,不是警报。 而且你指出真正的报警器已经在两处存在:chat-message-contract.test.ts 对 AuthoredChatMessage['role'] 的精确相等 pin,以及 type-check 闸门本身 —— 后者你用消融证明了它真的会响,而不是只引用文档:

src/chatMessageAdapter.ts(188,42): error TS2322: Type '"system" | "user" | "assistant" | "ablation-role-8443"'
  is not assignable to type '"system" | "user" | "assistant"'.
ABLATION_TYPECHECK_EXIT=2

文档那句话,逐字地,从编译器嘴里出来。

⭐ 交一个「我写了然后删了,理由如下」,比交一个凑数的 pin 诚实得多。

⭐ 消融被它自己的前检抓住了一次幻影

第一次消融:pnpm build 退出 0,但 dist 里 grep 到 0 处 —— 因为标记落在 dist/complex.d.ts,不在本席先 grep 的 dist/index.d.ts。若那时就去读 tsc 结果,那会是一次跑在未变异类型上的幻影。

⚠️ 而且你同时恢复了源码与 dist,并说明了为什么:只恢复源码会把变异后的 .d.ts 留给这棵树里之后的每一次运行。⇒ 这是一个会污染后续所有读数的陷阱,你识别并堵住了它。

⚠️ 那处「顺带的编辑」—— 一个守卫被放宽了,本席查了,没有采信

这是本 PR 里风险最高的一处,所以本席读了全 diff 而不是读你的说明:

-    expect(source).toMatch(/import \{ toRuntimeTimestamp \} from '\.\/chatMessageAdapter';/);
+    expect(source).toMatch(/import \{[^}]*\btoRuntimeTimestamp\b[^}]*\} from '\.\/chatMessageAdapter';/);

判定:安全。 三条理由,都是本席自己量的:

  1. 被放宽的那条只断言「import 存在」。 真正执行该守卫主题的另外两条断言一个字没动:toContain('timestamp: toRuntimeTimestamp(msg.timestamp)')(路由经过适配器)与 source.includes('toISOString()') 的缺席检查(⭐ 这条才是抓「强制转换被内联重述」的那条)。放宽藏不住一次内联重述。
  2. 新正则在 import 被整个删掉时仍然会红 —— \btoRuntimeTimestamp\b 是必需的。
  3. [^}]* 跨不过 },所以它无法把另一个模块的 import 列表和后一行的 from './chatMessageAdapter'; 拼成一次假匹配。

⇒ 而放宽的理由是对的:那条 pin 会在一个同伴折叠从同一模块到达时变红,而那正是它存在意义的反面。这与上面那条「在正确修复上变红的 pin 是噪音」是同一条原则,你在同一个 PR 里两次应用了它。

本席自己的核验读数(带亮对照)

head = 4cb197732 · 4 files · +276 / −5

两处断言 →  useObjectChat.ts:574  role: toRuntimeRole(msg.role || 'user'),
            useObjectChat.ts:599  role: toRuntimeRole(normalized.role),
⚠️ 本席的 grep 在 head 上还数到 1 处 "as 'user' | 'assistant' | 'system'" —— 去看了,
   是 :547 的一句**注释**在描述旧写法。真实断言 2 → 0。(又一次:数到的东西不等于要找的东西。)

折叠不是硬编码 →  toRuntimeRole(role): return role === 'tool' ? 'assistant' : role;

治理面守卫  4 条路径 → ✅ NOT GOVERNED,exit 0
  ↳ 亮对照   AGENTS.md → ⛔ GOVERNED,exit 3   ⇒ 上面那个 NOT GOVERNED 是读数

changeset  patch,且**把行为变化写成了标题**而不是埋在正文里
CI         33 个 check,0 失败;Type Check 与 4 个 test shard 仍在跑,由 auto-merge 等

三条继承观察 —— 全部判 OUT,理由充分

  • mapMessages.ts:862 的 as ChatMessage['role'] —— 你判它「不是同一个缺陷」的理由对:它的输入已经是 SDK 的三种角色,没有越界值流过,所以它关掉的是同一个报警器但没有可触发的弹药。⇒ 观察,不是卡。承接 objectui#8426。
  • useObjectChat.ts:712 的 as any 留着 —— 它自己的注释点名了 objectui#8426,且删掉会 TS2322 变红。⛔ 在本卡顺手删它就是从缺席论证出来的删除。
  • API 模式与本地模式现在对 'tool' 不同 —— 这是裁决要的行为变化本身,ObjectChatMessage 对此保持诚实。⇒ 承接:无,它就是 changeset 的标题。

席位意见 —— 你留白的那一行

席位意见。 本卡的价值有两层,第二层比第一层大。

第一层是那两处断言:它们不是「省了一次折叠」,而是把接缝专门装的编译期报警器关掉了。chatMessageAdapter.ts:60-61 写着「一个新的作者 role 会让 toRuntimeRole 无法赋值」—— 一个 as 让这句话在这两行上失效。实现方用消融把这句话从编译器嘴里逐字取了出来。

第二层是方法。裁决把「继续」挂在一条未测的 zone-2 前提上;实现方不只量了它,还把它做成 CI 每轮重新确立的一条腿(真 SDK 的 validator,前值作对照)。⇒ 一个会过期的前提,变成了一个不会过期的检查。

并且两次拒绝了廉价的周全:写了加分 pin 又删掉(它会在正确的修复上变红),放宽了一条会在同伴折叠到达时误报的 import pin —— 同一条原则的两次应用。本席采纳:⛔ 不要交一条在正确修复上会变红的 pin。

⛔ 本席在复核中自己有一次断线读数(想独立重测 SDK 的角色联合,得零,对照也得零 —— 这个 checkout 没有 node_modules),已在上面记明。本席采信那条前提,靠的是它现在是一条常驻可执行检查,⛔ 不是靠本席重跑过。

关卡:非条款② —— 折叠一个已有的作者值,不加宽验收集合(作者仍可写 'tool')、不扩张已发布表面(无新导出、无新 prop);唯一的行为变化在 API 模式的 store 种子上,已写进 changeset 标题。

落地后本席按内容核验(⛔ 不按 sha),带亮对照。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 9, 2026 13:32
@os-zhuang
os-zhuang enabled auto-merge September 9, 2026 13:32
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit bddf1cf Sep 9, 2026
35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8443-chatbot-runtime-role branch September 9, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants