Skip to content

refactor(app-shell): AiChatPage narrows the chat hook's messages through toRuntimeMessages (#4437) - #4445

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4437-aichatpage-adapter
Aug 12, 2026
Merged

refactor(app-shell): AiChatPage narrows the chat hook's messages through toRuntimeMessages (#4437)#4445
yinlianghui merged 1 commit into
mainfrom
claude/issue-4437-aichatpage-adapter

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4437

useObjectChat returns ObjectChatMessage[] — the shape both of its modes really produce (#4424 / PR #4436) — which is wide where local mode is wide: it keeps the authored 'tool' role and the legacy 'partial-call'/'call'/'result' tool states. AiChatPage wants the runtime shape, and said so five times:

survey row site was
4 sanitizeChatMessagesForCache(...) messages as ChatMessage[]
5 isConversationZh(...) messages as ChatMessage[]
6 useHitlInChat({ messages }) messages as ChatMessage[]
7 deriveBoundPackageId(...) messages as unknown as readonly PackageBearingMessage[]
8 the ChatbotEnhanced messages prop messages as ChatMessage[]

The narrowing was real and every cast was legal — that is the card's premise, and it survived re-verification at the branch point. What was missing is that a cast erases the whole difference rather than the intentional part of it: a new authored role, or a newly required runtime key, kept compiling at all five sites and surfaced as rendering behaviour.

The conversion

One conversion where the hook's values enter the page's runtime-typed world, memoized on messages exactly as the plugin's own three renderers do (#4399 / PR #4416):

const runtimeMessages = useMemo(() => toRuntimeMessages(messages), [messages]);

All five sites read it. No cast replaces them, and no new as is introduced — the as unknown as double cast at row 7 turned out to be unnecessary once the value is honestly typed, which is itself a small piece of evidence that the double cast existed to paper over the mis-declaration rather than over a real structural gap.

Placement is right after the useObjectChat destructure, above the first consumer. Identity is neither more nor less stable than before: the hook rebuilds apiMessages every render, so both the old messages as ChatMessage[] and the new memo change identity on exactly the same renders.

Per-site fold sensitivity — measured, not assumed

The card's binding clause. Each site was called twice — once on the honest pre-fold array (what the casts handed it), once on toRuntimeMessages(...) output — over a thread carrying a 'tool' role and two legacy states. The fold itself, measured:

roles  ['user','tool','assistant','user'] -> ['user','assistant','assistant','user']
states ['result','call']                  -> ['output-available','input-available']
site outcome measured
sanitizeChatMessagesForCache affected — change taken, it is the desirable one 3 deltas, below
isConversationZh unaffected zh-last true/true; en-last false/false; zh-only-on-the-tool-turn false/false
deriveBoundPackageId unaffected assistant-last app.crm2/app.crm2; tool-role-last app.crm/app.crm

Neither insensitive result is vacuous by construction: the zh probe includes a variant that answers true on both sides and one where the Chinese text sits only on the message whose role the fold rewrites; the package probe puts the 'tool' message last (the derivation is newest-wins), so a role-dependence would show as its packageId appearing or disappearing.

Both are insensitive structurally, not incidentally. isConversationZh reads role === 'user' and the text — the fold maps 'tool' to 'assistant', so it can neither create nor destroy a user turn, and content/parts ride the pass-through untouched. deriveBoundPackageId walks every message irrespective of role and reads draftReview/builderHandoff, which toRuntimeToolInvocation spreads through.

The one affected site, and why the change is the one it wants

sanitizeChatMessagesForCache declares its parameter's role as 'user' | 'assistant' | 'system'. It has always asked for folded roles — the cast is precisely what let an unfolded 'tool' past that declaration, so routing through the conversion makes the call type-checked rather than cast-asserted. Three measured deltas, all on the 'tool'-role message:

  1. cached role: 'tool' -> 'assistant'. readMessageCache's own validator accepts only user/assistant/system, so the pre-fold entry was written and then silently dropped on restore — the message disappeared on a cache-fallback reload.
  2. its tool-invocation part: absent pre-fold (sanitize gates tool serialization on role === 'assistant'), present post-fold — including the re-serialized draft envelope {status:'drafted', drafted:[…], packageId:'app.crm'}, i.e. the "Review N changes / Publish" affordance survives the reload it exists to survive only post-fold.
  3. a legacy state on an assistant turn: 'call' -> 'input-available' (that one was already serialized; only the spelling changed, and the restore path names the v6 states while catching legacy only via a default branch).

So the site's own semantics — cache round-trip fidelity, expressed in its own parameter type — want the folded value. Adapting at the last moment per call site was the alternative the card offered; it would have preserved a write the cache's reader rejects.

Reachability, stated so the table is not over-read: neither a 'tool' role nor a legacy state is producible on this page's own paths today. initialMessages comes from hydratedMessagesToChatMessages, whose roles are HydratedUIMessage['role'] (user/assistant/system) and whose states come from partToolState (v6 only); API mode's values come from mapMessages, also v6. The rendered-output bar is therefore zero at runtime right now, and this delta is a latent-correctness fix that becomes live the day such a value reaches the page.

Tests

packages/app-shell/src/console/ai/__tests__/AiChatPage.runtimeMessageSeam.test.tsx — 8 tests, three groups, driving the real page with a faked hook that hands out the honest unfolded shape:

  • the render path: a 'tool' message reaches the ChatbotEnhanced element as role: 'assistant' with its legacy state folded to 'output-available', and draftReview / pendingActionId survive the conversion (the pass-through);
  • useHitlInChat receives the same converted array (it reads the runtime-only pendingActionId);
  • the cache write: the tool turn is cached as an assistant message with its tool part and the re-serialized draft envelope;
  • the fold-sensitivity measurement itself, kept executable, so the two insensitive sites cannot quietly become sensitive and leave the placement unargued;
  • a source net (PR refactor(plugin-chatbot): useObjectChat declares the message shape it actually returns (#4424) #4436's precedent, comment-stripped — prose about a cast is not a cast) asserting no cast returns on the hook's messages, and that the conversion happens exactly once.

Reverse verification — direction predicted first

The card's premise is that the casts are legal, so "restore a cast and watch tsc" cannot be the probe: the prediction at every site is that it compiles. What has to red is a behavioural pin or the net. Predictions were written down before running; all three matched.

probe predicted measured
A restore row 8 (the ChatbotEnhanced prop) tsc green; render pin red; net red TSC=0, empty output. AssertionError: expected [ 'user', 'tool' ] to deeply equal [ 'user', 'assistant' ]. Net red. 2 failed / 6 passed
B restore row 4 (the cache write) tsc green; cache pin red; net red TSC=0. AssertionError: expected 'tool' to be 'assistant'. Net red. 2 failed / 6 passed
C restore row 5 (isConversationZh) tsc green; every behavioural test green; only the net reds TSC=0. 1 failed / 115 passed over the whole ai/ suite — the single failure is the source net

Probe C is the honest negative and the reason the net exists rather than being assumed. Stated plainly: of the five sites, only two can red behaviourally. Rows 5 and 7 are fold-insensitive (that is the measurement), and row 6 reads a key the pass-through preserves either way — a cast coming back at any of those three is invisible to every runtime assertion and to tsc, because a cast is exactly the instruction to stop checking. The net is what makes them non-silent; nothing else in this PR can.

Baseline restored after each probe, tree verified clean.

Verification

  • pnpm exec vitest run packages/app-shell/src/console/ai/116 passed (18 files), up from 108 passed (17 files) on origin/main: +8 tests in +1 file, nothing existing changed.
  • pnpm exec vitest run packages/app-shell/3377 passed | 1 skipped (355 files), 0 failed.
  • Both tsc commands for app-shell (tsc --noEmit and tsc -p tsconfig.test.json) — green, after pnpm --filter '@object-ui/app-shell^...' build (the package-level tsc resolves @object-ui/* through dist/*.d.ts, absent in a fresh worktree; only vitest and the root tsconfig alias to src).
  • eslint on both touched files — 0 errors. Warning count is 26 before and 26 after on AiChatPage.tsx (measured by running eslint on the origin/main copy of the file), i.e. no new finding on a touched line.
  • check-changeset-presence / check-changeset-no-major / check-changeset-fixed / check-control-bytes / check-phantom-dependencies — all exit 0.

Changeset: @object-ui/app-shell patch. Internal page wiring — nothing published moves: no export is added, removed or retyped, and toRuntimeMessages was already exported from @object-ui/plugin-chatbot's barrel by PR #4416. Never major (fixed group; check-changeset-no-major).

Observed and deliberately left alone

AiChatPage.tsx:862 carries a structurally identical as unknown as readonly PackageBearingMessage[] on hydratedMessagesToChatMessages(messages). It is not one of the card's five — that value is the page's own locally-produced runtime array, not the hook's messages — so it is out of this card's surface and untouched. Noting it because the same simplification would now apply there; not a defect, so not filed.


Generated by Claude Code

…ugh toRuntimeMessages (#4437)

`useObjectChat` returns `ObjectChatMessage[]` — the shape both of its modes
really produce (#4424 / PR #4436) — which keeps the authored `'tool'` role and
the legacy tool states the runtime type folds. This page wants the runtime
shape and said so five times with `as ChatMessage[]`, plus one `as unknown as`
double cast. The narrowing was real; what was missing is that a cast erases the
whole difference rather than the intentional part of it.

One conversion now sits where the hook's values enter the page's runtime-typed
world, memoized on `messages` exactly as the plugin's own three renderers do
(#4399 / PR #4416). All five sites read it and no cast replaces them; the double
cast turned out to be unnecessary once the value is honestly typed.

Per-site fold sensitivity was measured, not assumed:

- sanitizeChatMessagesForCache — AFFECTED, in the direction it wants. Its
  parameter declares `role: 'user' | 'assistant' | 'system'`, so it has always
  asked for folded roles and the cast is what let an unfolded `'tool'` past that
  declaration. Measured: the entry was cached as `role: 'tool'`, which
  `readMessageCache`'s own validator rejects on the way back in, and its tool
  invocations were dropped entirely because sanitize gates them on
  `role === 'assistant'` — including the re-serialized draft envelope behind
  "Review N changes / Publish".
- isConversationZh — UNAFFECTED. Reads `role === 'user'` and the text; the fold
  can neither create nor destroy a user turn.
- deriveBoundPackageId — UNAFFECTED. Walks every message irrespective of role
  and reads keys the adapter spreads through.

Nothing on screen moves today: neither a `'tool'` role nor a legacy tool state
is producible on this page's own paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 8:07am

Request Review

@github-actions github-actions Bot added the tests label Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-BpBjNi2K.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 489.20KB 108.43KB
core (index.js) 2.99KB 1.14KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.42KB 41.19KB
fields (index.js) 228.99KB 56.82KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.01KB 17.63KB
plugin-chatbot (index.js) 181.17KB 43.03KB
plugin-dashboard (index.js) 120.75KB 31.38KB
plugin-designer (index.js) 211.16KB 42.76KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 187.99KB 49.92KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.21KB 26.79KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.99KB 10.74KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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
Collaborator Author

ACCEPT — PM 复核 (session session_017Qqyix2QcnpUC9XeYVDzx3), closes #4437.

  • The affected-site finding vindicates the measure-first condition: the cache write's pre-fold 'tool' entries were silently DROPPED by the cache's own reader on restore — a vanishing message on cache-fallback reload — and the parameter type had always demanded folded roles; the cast was the instrument of the lie. Taking the change (type-checked over cast-asserted, round-trip fidelity restored) with its latent-not-live status stated plainly is the honest grading.
  • The RV epistemics are the review's centerpiece: casts are legal, so the prediction at every site is "tsc green" — of five sites only two can red behaviourally, the fold-insensitive rows cannot red BY CONSTRUCTION (that's the measurement), and Probe C converts the source net from a precaution into a measured necessity. Making the net permanent on that evidence is the right deviation.
  • The vitest-aliases-src vs tsc-resolves-dist asymmetry (245KB of bogus TS2307s in a fresh worktree) is process capture the loop keeps; the :862 sibling cast correctly noted as out-of-surface and not-a-defect. Zero existing tests changed, +8 pins, CI converged, patch verified not asserted.

Flipping ready + arming auto-merge.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 08:17
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit d8d0d66 Aug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4437-aichatpage-adapter branch August 12, 2026 08:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AiChatPage narrows useObjectChat's messages with 5 casts — route it through the exported toRuntimeMessages instead (the #4399 move, one hop up)

2 participants