# fix(core): keep exactly one tool result per tool call across message
snapshots
## Summary
When an agent emits a MESSAGES_SNAPSHOT, AG-UI merges it by message id
and can drop a tool message that TOOL_CALL_RESULT created. The next turn
then sends an assistant tool call without its paired result, which
providers reject.
This PR records observed tool results for the current input and repairs
history through AG-UI's returned-messages mutation channel. It keeps one
tool message per toolCallId and composes with current main's first-seen
message provenance.
## Root cause
@ag-ui/client applies events against its own cloned messages array.
TOOL_CALL_RESULT creates a tool message and inserts it after its
assistant owner. A later MESSAGES_SNAPSHOT is a replace-by-id merge, so
a missing tool entry can remove that result. packages/core had no record
of the result event, so there was nothing to restore it from.
## What changed
- StateManager records one ToolCallResultEvent per toolCallId on the
current RunAgentInput. Events keep flowing through AG-UI's normal path.
- At RUN_FINISHED, RUN_ERROR, and onRunFailed, reconciliation returns a
fresh message array only when a repair is needed. AG-UI applies it
through its normal mutation chain.
- Pending results are released at each finished server-run boundary and
at finalization, so a later server run under one input cannot resurrect
a result removed by its snapshot.
- The "Forwarded to client" sentinel classifier now lives in one
internal module used by StateManager and run-handler.ts.
## The reconciliation rule
toolCallId is the decisive identity:
- No assistant owner for the call: do nothing.
- A real tool message already exists for the call under any message id:
keep one result and remove duplicate same-call representations.
- Only placeholders exist: promote one to the canonical result and drop
the rest.
- Nothing exists: insert the result after its assistant owner and its
contiguous tool messages.
## On the LangGraph duplicate
LangGraph can represent one result with different streamed and
checkpoint message ids. The regression fixture keeps the streamed result
before the snapshot and places both representations in the snapshot. The
final history and next-turn input contain one result for that
toolCallId.
Two tool messages for one toolCallId are one malformed history class.
Keying reconciliation by toolCallId makes that duplicate unrepresentable
while preserving normal event delivery.
## What this does not do
- No direct agent.messages mutation, setMessages from a subscriber, or
stopPropagation. AG-UI remains the owner of message application,
ordering, and publication.
- Reconciliation does not infer or rewrite run identity. Current main's
event-derived run identity and first-seen snapshot provenance remain
intact.
- No message-to-run association is performed inside reconciliation.
- No public API change, export, version bump, or changeset.
## Relationship to #3884
Related to #3884, but not marked as closing it. The event sequence in
that issue has no MESSAGES_SNAPSHOT, and it already produces a correct
turn-2 history on current main. Snapshot-dropped results are a real bug
worth fixing independently, while the reporter's case still needs a raw
event trace.
## Test plan
All cases drive CopilotKitCore.runAgent() against real AbstractAgent
subclasses.
- Two-turn reproduction: a snapshot omits the result, and the next turn
receives exactly one result.
- LangGraph shape: differing message ids under one toolCallId produce
one surviving tool message in real event-before-snapshot order.
- Repeated server runs under one input do not resurrect a result removed
by a later snapshot.
- Terminal mutation, normal result propagation, duplicate results,
placeholders, ownerless results, ordering, RUN_ERROR, local failure, and
run ownership remain covered.
- The focused core tests pass 32/32. Core typecheck, build, formatting,
lint, and diff checks pass. CI checkboxes remain for GitHub.
## What does this PR do?
`@copilotkit/react-native` maintained a **private tool-call render
registry** (`hooks/RenderToolContext.tsx`) alongside the canonical one
that `CopilotKitCoreReact` already provides — and which every React
Native app already ships, unused. This PR deletes the fork and points
React Native at the shared registry.
That fork caused three bugs:
| Bug | Symptom | Cause |
|---|---|---|
| **Tool renders never streamed** | A component registered with
`useRenderTool` / `useComponent` painted nothing until the tool call
completed | `CopilotChat` used `JSON.parse` on the argument buffer.
While a model writes a tool call that buffer is *invalid JSON by design*
— AG-UI delivers `TOOL_CALL_ARGS` deltas that are concatenated
client-side — so the parse threw on every delta, warned, and fell back
to `{}` |
| **`useComponent` rendered nowhere** | Silently, with no error | It
writes to core's registry; React Native's chat read React Native's
private `Map` |
| **Chat history degraded** | Navigating away from the registering
screen turned earlier tool calls into a `Called: <name>` placeholder |
The private `Map` deleted renderers on unmount; core deliberately keeps
them |
`@copilotkit/react-core` has used `partialJSONParse` on this path since
v2 shipped. React Native diverged because `useRenderToolCall` was
excluded from its re-exports on the stated grounds that it "depends on
DOM elements via `DefaultToolCallRenderer`" — a claim that was never
true of the hook itself. It was only ever reachable through the fat
`/v2` entry, whose weight is the real hazard (#4893). #5883 moved it
into `/v2/headless` on 2026-07-23; the exclusion comment was rewritten
the next day without revisiting the reason.
### What changed
- **One registry.** `useRenderTool` registers through `useFrontendTool`
into `CopilotKitCoreReact.renderToolCalls`. `CopilotChat` and any custom
surface consume react-core's `useRenderToolCall`.
- **Types are derived, not declared.** `RenderToolProps` is now
`React.ComponentProps<ReactToolCallRenderer<T>["render"]>`, so React
Native cannot drift from `ReactToolCallRenderer` — the contract every
registered renderer is actually invoked against. Change that contract
and `check-types` names every React Native renderer the change breaks.
React Native narrows only the *return* type to `ReactElement | null`,
which `FlatList`'s `renderItem` genuinely requires.
_Scope of that guarantee (corrected during review):_ it does **not**
extend to the type react-core publicly exports under the same name.
Web's `RenderToolProps<S>`
(`react-core/src/v2/hooks/use-render-tool.tsx`) is a separate
hand-declared union, generic over a schema, carrying arguments under
`parameters` (not `args`) and declaring `status` as string literals
rather than `ToolCallStatus` members. Both divergences are live today
and nothing type-checks them shut — the one place the shapes meet,
react-core's own bridge, compiles because a string-enum member is
assignable to its own literal type but not the reverse. Aligning web's
alias is a breaking web API change, filed separately.
- **`RenderToolContext.tsx` deleted** (−150 lines), along with 15 tests
that described the removed subsystem. One of them — `unregisters the
render function on unmount` — asserted the chat-history bug as a
requirement.
- **Two structural CI guards for #4893**, in opposite directions: a test
failing if any React Native source imports the fat `/v2` entry, and a
script failing if react-core's `/v2/headless` or `/v2/context` chunks
ever link shiki/mermaid/cytoscape/katex/streamdown. Both were verified
able to fail by deliberately introducing the regression. These are
*structural* assertions, not size budgets — `dev-docs/bundle-size.md`
freezes `limit` fields until OSS-122.
- **`react-native` added to the bundle-size glob**, which it had never
been in, plus a `size:headless` measurement.
React Native also gains capabilities it lacked: render props inferred
from your schema, `name`/`toolCallId` on render props, and `result` on
completed calls.
**Corrected during review — two capabilities this originally claimed are
not delivered:**
- **Wildcard (`"*"`) renderers do not work on React Native.** Because
`useRenderTool` routes through `useFrontendTool` (which calls
`addTool`), `name: "*"` registers a frontend tool literally named `*` —
advertised to the model, and colliding with core's separate
wildcard-executable-tool path. react-core's `useRenderTool` is
renderer-only and special-cases the wildcard; React Native's is not. The
guide now advises against it.
- **`followUp` (and `available`) are not forwarded**, and the handler's
`context` argument is dropped, so `stopAgent()`'s abort signal is
unreachable from an RN handler.
Both are tracked in § Known limitations for the follow-up that converges
React Native onto react-core's hooks — deleting RN's `useRenderTool` in
favour of re-exporting `useFrontendTool` (tool + renderer) and
react-core's `useRenderTool` (renderer-only, wildcard-capable). That is
an API change with its own migration note, so it is not in this PR.
### ⚠️ Breaking (in a minor)
`useRenderToolRegistry` and `RenderToolProvider` are **removed**. Both
are documented on the docs site, so this is a real break — see the
`BREAKING CHANGE:` footer on `db67ccf`, which is what the release notes
derive from, plus the rewritten reference pages.
```diff
- const registry = useRenderToolRegistry();
- const renderer = registry.get(toolCall.function.name);
- return renderer ? renderer({ args, status }) : null;
+ const renderToolCall = useRenderToolCall();
+ return renderToolCall({ toolCall });
```
Also note two semantic changes: `args` is `Partial<T>` **only** while
`status` is `"inProgress"`, and a render function is now captured at
registration — if it closes over changing values you must declare them
in `deps` (React Native previously refreshed the closure on every
render).
**Known limitation:** agent-scoped renderer resolution does not take
effect on React Native. `CopilotChatConfigurationProvider` is not in
RN's provider tree, so `agentId` always resolves to the default.
Renderers still resolve by name; two agents registering the same tool
name resolve arbitrarily. Filed separately.
### A data point worth recording
Adding `useRenderToolCall` to the measured headless entry moved the
bundle **92.8 kB → 92.7 kB**. Flat. The hook React Native spent months
not using was already inside the chunk every RN app resolves whole —
Metro doesn't tree-shake, so the fork never saved a byte. It cost them.
### Testing
- `@copilotkit/react-native`: **253 passing / 22 files** ·
`@copilotkit/react-core`: **1480 passing / 123 files** · `check-types`
and `build` green for both.
- Each of the three bugs has a deterministic test driving a real
`CopilotKitCoreReact` — no mocking of the code under test.
- Both #4893 guards carry mutation evidence: introduce the regression,
watch them fail, revert, watch them pass.
### Follow-up
`useRenderTool`'s JSDoc is split across two blocks, which orphans the
primary description from IDE hover (the `@param deps` warning still
surfaces). One-line fix, deliberately left out of the final fix wave.
## Related PRs and Issues
- **Supersedes #6346** (@davidmckayv) — its diagnoses were correct and
its test assertions are ported here, re-driven through the real registry
rather than a mocked local one. Credited via `Co-Authored-By` on
`4104bd1`.
- Addresses the React Native half of **#4893**.
- Builds on **#5883**, which created the lean `/v2/headless` entry this
PR consumes.
## Checklist
- [x] I have read the Contribution Guide
- [x] If the PR changes or adds functionality, I have updated the
relevant documentation
- [x] "Allow edits by maintainers" is checked
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`messagesFingerprint`'s content key collapsed every object to 0, so an
in-place content replacement that kept the same message id was invisible to
every memo derived from it. Its comment claimed to mirror react-core's
`messagesMemoKey`, which stopped being true when react-core #6325
(de0a659b2d) taught that key to serialize object content.
Serialize object content here too, keeping the length-not-value treatment for
string and array content so large text and base64 attachment payloads are
still never re-serialized per render. Serialization is guarded: the
fingerprint runs on every render and `JSON.stringify` throws on a circular
structure, which this component is already required to tolerate (see the
existing "does not throw on tool content that cannot be JSON-serialised"
assertion) — react-core stringifies unguarded, so the guard is a deliberate
and documented divergence.
Not a live stale-render bug via the activity path: same-id object content
comes from an ACTIVITY_SNAPSHOT replace, and `role: "activity"` never reaches
`listItems`, which builds rows for `user` and `assistant` only. Object content
DOES reach a renderer through the `role: "tool"` correlation, though, which is
what the added test drives: an object tool result replaced in place used to
leave the renderer showing the first object's serialization.
The comment no longer claims to mirror a moving target — it records what the
key captures, why the object branch exists, and that the two implementations
are independent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both scripts decided "am I the entrypoint?" by comparing `import.meta.url` to a
`file://`-concatenated `process.argv[1]`. `import.meta.url` is percent-encoded
and symlink-resolved; raw argv[1] is neither. So the comparison was false for
any checkout path containing a space, for any invocation through a symlink
(macOS /tmp is one), and on Windows — and a false guard skipped the whole CLI
block. Reproduced before fixing: the #4893 purity gate and the bundle-size
measurement both exited 0 having printed nothing and asserted nothing, which is
worse than a gate with holes because it manufactures confidence. The guard was
added by this PR so the modules could export internals to their new negative
tests; making the gates testable introduced a way for them not to run.
Both now compare real filesystem paths through an exported `isEntrypoint`:
`fileURLToPath` defeats the encoding and Windows forms, `fs.realpathSync` on
both sides defeats symlinks, and a `path.resolve` fallback keeps a nonexistent
argv[1] from throwing.
Each `node --test` suite gains five entry-guard tests, including an end-to-end
spawn of the real script through a symlinked package-root alias whose name
contains a space — the only case that catches the call site regressing back to
a string comparison (verified: it fails against the old expression). The unit
cases assert the naive comparison really would have failed, so none of them can
pass vacuously. Both negative gates were re-proven to still bite: a doctored
dist entry pulling streamdown fails the purity gate, and a stubbed dist entry
trips the measurement's plausibility floor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The headless import-graph guard pinned the resolved graph EXACTLY — the
11-module list and the 8 bare specifiers, both `toEqual`. That catch-all was
deliberate (a heavy dependency nobody enumerated still had to be looked at),
but it also went red on innocent growth: adding any first-party `src/` module
to the headless graph failed it, on someone else's unrelated PR. A guard that
fails on innocent changes gets deleted by the third person who hits it, and
then it guards nothing.
Express the catch-all over PACKAGES instead of MODULES: the graph may only
reach packages a headless consumer is guaranteed to be able to resolve — this
package's `dependencies` plus its NON-optional `peerDependencies`, read from
package.json rather than hand-copied. That is precisely the promise the
headless entry sells ("bundles with nothing stubbed in metro.config.js"), so
it still fails on any new third-party edge, on every optional peer, on a
devDependency, and on a Node builtin — while a new first-party module or
another import of an already-sanctioned package is free.
The two other things the pin bought are kept explicitly:
- Comment stripping. The eight phantom specifiers JSDoc examples used to
harvest were all self-references, and this package's own name is not in the
guaranteed set, so a `stripComments` regression still fails here.
- Non-vacuity. Every remaining graph assertion is a deny-list, and a deny-list
over a truncated graph passes for the wrong reason, so a subset floor
asserts the walk still reaches the provider, the polyfills and the
react-core headless edge.
Not changed: comment stripping itself, the import()/require()/require.resolve
extraction, non-literal loader flagging, emitted-extension resolution, the
loud failure on unresolvable edges, the entry-presence tests, the #4893
fat-entry ban (still the assertion that catches `@copilotkit/react-core/v2`)
or the heavy-dependency ban. The runtime-export `beforeAll` is untouched.
Proven both directions: a new first-party module passes the loosened guard and
fails the old pin; `@copilotkit/react-core/v2`, `shiki`, an unenumerated
devDependency edge and a truncated walk each fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`messagesFingerprint`'s header JSDoc and the matching inline comment near the
`listItems` memo justified keying on message CONTENT with a claim that is false:
that `agent.messages` is mutated in place throughout, and that "the AG-UI apply
pipeline reuses one array for a whole run".
It does not. `@ag-ui/client`'s `AbstractAgent.processApplyEvents` REASSIGNS
`this.messages = applied.messages` for every applied event, so a streaming run
hands down a new array — and new message, `toolCall` and `function` objects — per
delta. Verified against a real AG-UI run by the PR reviewer, and confirmed here in
@ag-ui/client 0.0.57's `AbstractAgent`. The old grep behind the claim ("assigning
`.messages` in packages/core/src hits test files only") is accurate but proves
nothing: `@ag-ui/client` is a dependency, outside that tree.
The fix itself stands. Identity is unreliable in BOTH directions, which is the
actual rationale: it changes on the apply path, and it does NOT change on the
paths these memos exist to serve — core splices tool results in place
(`agent.messages.splice(insertAt, 0, toolMessage)`,
packages/core/src/core/run-handler.ts:931, :1080), `AbstractAgent.addMessage` is a
`this.messages.push(...)`, and `useAgent` re-renders with a bare `forceUpdate()`
(packages/react-core/src/v2/hooks/use-agent.tsx:382-396). A signal that both
misses changes and fires without them cannot be a dependency, so the derivations
must key on content.
Comments only: three sites reworded (the JSDoc, the "cannot be used" pointer at
the `messagesKey` call, and the inline note on the `listItems` memo). `git diff`
touches no behaviour, type or dependency array — every changed line is a comment.
The `contentKey` length-vs-value paragraph is left alone; another change owns it.
Note: the same false claim is in commit 77ed31c437's body, which cannot be
rewritten, and in a GitHub review comment.
Not run in this worktree: it has no node_modules, and the change is comment-only,
so it cannot affect types, lint or tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The #4893 entry-surface guard timed out nondeterministically at full test
parallelism ("Test timed out in 5000ms" on `await import("../headless")`),
which four independent agents each worked around with --testTimeout or
--maxWorkers=2. A flaky hard gate is a gate people learn to ignore.
Measured, not guessed. The import is a one-time module-graph load whose
VARIANCE — not its mean — broke the default budget: ~0.7-1.1s for this file
alone and ~0.9-1.8s inside the full 22-file suite (n=8 each), but 4568ms on
the run straight after a cold `nx build`, i.e. 91% of the 5000ms budget spent
on an otherwise idle machine. The cost is resolve/transform plus cold-page-
cache I/O over the ~283 KB of workspace dist that vitest.config.mjs inlines
via `server.deps.inline: [/@copilotkit/]` (core ~218 KB, react-core
v2/headless ~55 KB, v2/context, shared); bare-Node `import()` of the
equivalent prebuilt dist is 461ms, so evaluation is not the expensive part.
Four separate tests each awaited that same import, so all four raced one
cost against one budget — and when the first lost the race the other three
inherited its in-flight import and timed out with it, which is why the
observed signature was three simultaneous failures rather than one. They now
share a single explicitly-budgeted `beforeAll`, so the cost lives in exactly
one place and each test reports ~0ms.
The hook is nested rather than top-level on purpose: its failure domain must
cover only the tests that need the module, or an import failure would take
down the fs-only graph tests too — the same blast-radius problem as blind
spot #4, just relocated into a hook.
No assertion is weakened: comment stripping, import()/require() extraction,
the exact resolved-graph pin and the revived existence test are untouched,
and the guard still fails on a real violation (injecting a lazy
`import("@copilotkit/react-core/v2")` into src/streaming-fetch.ts trips 3
assertions; reverted).
Verification: 5 consecutive full-suite runs at DEFAULT parallelism, no
--maxWorkers or --testTimeout override, 271/271 passing in 3.97-5.87s wall
each; plus 3 concurrent full suites (30 workers on 10 cores) all green, and
one pass at load average 235. `check-types` clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The esbuild `external` list omitted `react-dom`, unlike react-core's
measure-copilotchat.mjs, so a stray web-oriented edge could be absorbed
into the figure the PR's bundle claim rests on.
Checked empirically before changing anything: `react-dom` is NOT reachable
from @copilotkit/react-native/headless today. A metafile run shows 0 of the
653 input modules are react-dom, and no module references it even
pre-resolution. The reported figure is therefore UNCHANGED — 94941 B gzip
(92.7 kB) before and after, byte for byte. The headline "92.8 kB -> 92.7 kB,
flat" claim is unaffected and stays comparable with previously reported
numbers.
The guard is still worth having. Simulating a stray edge measures the
inflation it prevents: +56.3 kB gzip via react-dom/client, +57.3 kB via
react-dom/server (not the ~130 kB estimated in review — that is closer to
the raw magnitude; react-dom-client.production.js is 536 kB raw). The
subtler case is a bare `react-dom` edge at +1.4 kB, small enough to read as
noise while still being a real regression.
No subpath entries: esbuild prefix-matches package paths, so `react-dom`
already covers react-dom/client and react-dom/server (verified on the
pinned 0.27.3; esbuild CHANGELOG 0.5.14 and 0.14.13). Listing them would
imply they were required.
Hoisted the list to an exported HEADLESS_EXTERNAL with per-entry rationale,
mirroring the sibling's DEFAULT_EXTERNAL, and made `external` an overridable
option so the new test can A/B it rather than assert on a literal. Both new
tests fail if react-dom is removed from the list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two tests claimed to prove a tool RESULT reaches its renderer and neither
read it. "reports complete and passes the result through" rendered through a
registrar printing only status and args, so replacing the correlated tool
message's content with a constant left it green; its in-place-mutation twin
had the same hole. The integration test's only result assertion built its
tool message as `{ content: "ok" }` behind an `as never`, so it carried no
toolCallId — the id production correlates a result to a call by.
Both now render status, args AND result together, and a new negative case
gives a tool call a result belonging to a DIFFERENT call. That last one is
the only detector for a lookup that ignores the map key: every fixture in
the file matched on id, so an unkeyed "hand out any tool result we have"
lookup passed the whole suite unchanged.
Fixtures move to src/__mocks__/tool-fixtures.ts. toolMessage() takes
toolCallId as a required positional argument, so no fixture can omit the
correlation, and assistantToolCall() returns a typed AssistantMessage —
which retires the `as never`, an `as unknown as Message`, and three `any`s
in the touched files. A properly-typed ToolMessage typechecks at that call
site unchanged; the cast was convenience, not a type-system limit.
Test-only: CopilotChat.tsx is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`export type { X }` strips X's runtime binding. Five runtime values shipped from
`src/headless.ts` inside `export type` blocks — the `ToolCallStatus`,
`UseAgentUpdate`, `CopilotKitCoreErrorCode` and
`CopilotKitCoreRuntimeConnectionStatus` enums, and the `AbstractAgent` class —
while the reference docs told consumers to import and branch on them. Nothing in
the repo could see it: the package built, typechecked, linted and passed its
suite, because nothing here consumed its own entry the way a consumer does.
react-core's `headless-type-exports.test-d.ts` cannot cover this. Export kind is
a property of the re-exporting module, and that guard reads react-core's entry —
react-core's own `UseAgentUpdate` was already a correct value export while RN's
was wrong. The guard has to live on the RN side and read RN's own entries.
Adds `src/__tests__/headless-value-exports.test.ts`, in three layers:
- §1 asserts each of the five is a present runtime binding of the expected
`typeof`, with its enum members nameable, on BOTH `@copilotkit/react-native`
and `@copilotkit/react-native/headless`. A stripped export is an absent module
binding, so a runtime test is the direct instrument and cannot be faked by a
cast or an expect-error.
- §2 needs no symbol list: it parses both entry sources, and for every symbol
re-exported type-only it imports the module that symbol came from and fails if
that module has a runtime binding for it. A future contributor who adds a new
enum re-export inside an `export type { … }` block is caught without anyone
updating §1, and the failure names the symbol, the source module and the fix.
A floor on the parsed specifier count keeps a rotted parser from passing
vacuously.
- §3 type-checks the consumer-visible symptom (enum-member comparison on a
render-prop `status`, `extends AbstractAgent`, `instanceof`). The file lives
under `src/`, so `check-types` compiles it and a regression also fails there
with TS1362 naming the symbol. Its bodies are lazy on purpose: a module-scope
`extends` would crash collection and hide §1/§2's guided messages.
Proven by transiently restoring the defect three ways — the `AbstractAgent`
class, `ToolCallStatus` moved into an `export type` block, and `UseAgentUpdate`
via the inline `type ` prefix. Each produced 4 failing tests plus TS1362;
`src/headless.ts` is byte-identical to before.
RN suite 23 files / 276 tests (baseline 22 / 261, so +15 and no change
elsewhere); `nx run @copilotkit/react-native:check-types` clean; oxfmt and
oxlint clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
measure-headless.mjs prints the number the PR's bundle claim rests on, and it
had three ways to report a broken run as a good one. All three reproduced:
1. No zero-output guard (the react-core sibling has one). A run whose bundle
collapses to nothing measures ~20-35 B of gzip envelope, prints "0.0 kB"
and exits 0 — reported into the CI job summary as a spectacular win. Note a
zero-ONLY guard would not have caught the reproduction (35 B, not 0), so
this adds a plausibility FLOOR of 8 kB alongside the zero check: ~11x below
the real 92.7 kB, so legitimate size work can never trip it.
2. `logLevel: "silent"` discarded `result.warnings` and there was no
try/catch, so esbuild resolution problems escaped as an unhandled rejection
printing esbuild's internal frames and `errors: [Getter/Setter]` instead of
the messages. Silent is kept (as in the sibling) so stdout stays the single
figure line CI quotes; warnings are now formatted to stderr and errors are
re-thrown with esbuild's own formatted diagnostics.
3. An unbuilt dist died on a raw "Could not resolve" stack. A preflight on
dist/headless.mjs now names `npx nx run @copilotkit/react-native:build`,
and the catch adds the same hint when the entry specifier is what failed.
The measurement itself is untouched — same esbuild options, same synthetic
entry, same six symbols, same external list — and still reports 92.7 kB, so
comparability across PRs is preserved. A moved figure would have meant the
measurement changed rather than its guards.
Also adds the test hook RN lacked, mirroring react-core exactly:
scripts/__tests__/measure-headless.test.mjs under `node --test`, wired as
`test:scripts` and chained into `test`. Coverage targets the failure modes,
not the happy path. Both packages' vitest `include` globs are scoped to
`src/**`, so the .mjs test cannot collide with the jsdom setup — the reason
the sibling runs under node --test in the first place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The headless-entry import-graph guard was weaker than the PR claimed. Four
blind spots, each verified to let a real violation pass (or to flag a
non-violation), each now covered by a test:
1. Only `import … from "x"` was matched, so a lazy optional-peer
`require("@copilotkit/react-core/v2")` or `await import(…)` — which Metro
follows and bundles identically — defeated the guard entirely. Static,
bare side-effect, dynamic `import()` and `require()`/`require.resolve()`
are all extracted now, and a loader whose argument is not a string
literal is reported as unanalyzable rather than silently skipped.
2. Matching ran on raw text, so doc comments counted as imports. Not
hypothetical: the guard was harvesting EIGHT specifiers
(`@copilotkit/react-native`, `…/headless`, `…/polyfills` and its five
subpaths) that no source file imports — half the reported bare-specifier
set — purely from JSDoc examples. In the other direction, writing a
"don't do this: import from @copilotkit/react-core/v2" counter-example
in a doc comment failed the build. Comments are stripped first now, via
a single left-to-right pass that matches string/template literals with
the same alternation so a `//` inside a string stays a string.
3. `resolveLocal` returned null for an edge it could not resolve and the
caller dropped it, so an unresolvable specifier read as "clean" while
hiding the whole subgraph behind it. Proven: a real
`export … from "@copilotkit/react-core/v2"` reached through an ESM-style
`"./probe-heavy.js"` edge passed the old guard. Emitted-extension
specifiers now resolve, and anything still unresolvable FAILS LOUDLY
instead of vanishing. The resolved file set and bare-specifier set are
also asserted EXACTLY, so a new edge has to be looked at deliberately
rather than only being caught if someone thought to deny-list it.
4. The graph was walked in the `describe` body, so a missing entry file
threw at collection time and every test in the file — including the one
asserting the entry exists — never ran (`Tests no tests`). The walk is
lazy and memoized per entry now, and the existence assertion reports.
Every fix was proven by mutation in both directions: the violation passes
the old guard, fails the new one, and clean source still passes. Also drops
`localFiles`, which no test ever read.
Scope note: the ~5s `await import("../headless")` timeout flake in this
file is deliberately untouched — it is owned separately. Runs used
`--testTimeout=60000`.
RN suite 267 passed / 22 files (was 261; +6 new tests);
`nx run @copilotkit/react-native:check-types` clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`useRenderTool` is a thin forwarder onto react-core's `useFrontendTool`, and
its suite mocked exactly that hook. The double only modelled `name` and
`render`, so deleting the `deps`, `handler` AND `agentId` forwarding from the
hook each left the suite fully green — it could not detect a regression in any
of the three things the hook exists to forward.
Drop the four `vi.mock` blocks and drive a real `CopilotKitCoreReact` through
the shared `TestCopilotKit` harness, the way the sibling
`render-tool-call.integration.test.tsx` already does, then assert on core's own
observable behaviour instead of a mock's call arguments:
- handler — `core.runTool()`, i.e. core's real `executeToolHandler` path, so the
handler is proven to RUN and its return value proven to become the tool result
- agentId — the tool resolves for its agent and must NOT resolve as a global
tool, and the renderer entry carries the agentId that keys it
- deps — a render closure over a serialisable dep re-registers and the PAINTED
text changes, observed through react-core's real `useRenderToolCall`
Each mutation now fails exactly one test. Also pins the documented sharp edge
that `useFrontendTool` compares deps with `JSON.stringify`, so a function dep
collapses to a constant and can never re-register — a test asserting otherwise
would assert a behaviour the code cannot deliver, and pinning it makes a change
of comparator fail loudly.
Test-only: `useRenderTool.ts` is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CopilotChat's `extraData` memo carried a comment claiming it held "the exact
inputs renderItem reads", but it listed only { isRunning, renderToolCall,
toolMessages } while renderItem also read `listItems` — it answered "am I the
last row?" by index-reading the array's tail, and depended on `listItems` in
its own useCallback deps. The comment was false and the stated
row-memoisation contract was incomplete.
The defect is documentation and fragility, NOT observable behaviour. Verified
against the real react-native 0.85.2 sources in the pnpm store:
- FlatList is a PureComponent (Libraries/Lists/FlatList.js:307), and `data`
is one of the props it shallow-compares. `data={listItems}` is the same
reference, so any rebuild of `listItems` re-renders FlatList on its own.
- In the default non-strictMode path FlatList's render() uses `this._renderer`
rather than `this._memoizedRenderer` (FlatList.js:682), allocating a fresh
`renderProp` on every render, which is handed to every cell.
- VirtualizedList._pushCells passes that `renderItem` plus `item` to each
CellRenderer, which is itself a PureComponent
(VirtualizedListCellRenderer.js:63). `extraData` is NOT a cell prop.
- The `listItems` memo allocates fresh item objects on every rebuild, so each
cell's `item` prop also differs. Cells therefore invalidate through
`data`/`item` even under the narrowest path (strictMode with a memoizeOne
hit on renderItem and extraData).
So no stale last-row / stranded-loading-indicator state is reachable, and no
covering test is added: the behaviour is unchanged, and the package's test
FlatList is a mock that re-invokes renderItem for every row on every parent
render, so it cannot express cell memoisation in the first place.
Instead, make the contract honest. The tail id becomes a named `lastItemId`
memo; renderItem reads that scalar and deps on it rather than closing over
`listItems` and indexing it; `extraData` now lists exactly the four values
renderItem closes over, and the comment states why `listItems` is absent
(it is the `data` prop, which already invalidates cells). As a side benefit
renderItem's identity is now stable across `listItems` rebuilds that do not
move the tail.
Call-Site Enumeration (Procedure 2 step 8):
- `extraData` — grep over packages/react-native/src shows exactly two sites,
the memo itself and the `extraData={extraData}` prop on the list. No test
and no other module reads its keys. A caller-supplied `FlatListComponent`
(the documented BottomSheetFlatList case) receives it, but RN treats
extraData as an opaque re-render marker and never inspects its shape, so
adding `lastItemId` is not observable to any consumer.
- `renderItem` / `lastItemId` — local to CopilotChat; neither is exported.
- `isLoading` on AssistantMessage — value-identical by construction, since
`lastItemId` IS `listItems[listItems.length - 1]?.id`.
- No change to CopilotChatProps or to any entry-point export.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`src/headless.ts` re-exported five runtime values inside `export type { … }`
blocks. A type-only re-export strips the runtime binding, so the symbol is
unimportable and — for the enums — the field it types cannot be compared
against at all, because an enum-typed field rejects a bare string literal.
`RenderToolProps["status"]` is `ToolCallStatus`, so a consumer of this PR's own
render-prop contract had no working way to branch on `status`.
Determined empirically, not by reading: a throwaway probe imported all 32
re-exported symbols as values under the package's real tsconfig. The 27 that
are genuine types reported TS2693 ("only refers to a type"); five did not, and
those five are the ones moved. Declaration sites confirm each:
ToolCallStatus packages/core/src/types.ts:14 export enum
CopilotKitCoreErrorCode packages/core/src/core/core.ts:99 export enum
CopilotKitCoreRuntimeConnectionStatus packages/core/src/core/core.ts:320 export enum
UseAgentUpdate packages/react-core/src/v2/hooks/use-agent.tsx:13
export enum
AbstractAgent @ag-ui/client declare abstract class
Nothing else changed kind: Suggestion, FrontendTool, Message, ToolCall,
ToolMessage, AgentCapabilities, ResumeStatus, Interrupt, ResumeEntry, the
Interrupt*/RenderTool*/Thread*/CopilotChat* prop and config types,
ReactFrontendTool, ReactHumanInTheLoop, ReactToolCallRenderer and
CopilotKitContextValue are all genuine types and stay `export type`.
`src/index.ts` does `export * from "./headless"`, which republishes values and
types alike, so both published entry points are fixed. Verified in the built
output: all five appear without a `type` prefix in dist/headless.d.mts and
dist/index.d.mts, and as runtime bindings in headless.mjs, index.mjs and
index.cjs.
Negative control: with the pre-fix headless.ts the same probe produced ten
TS1362 errors ("cannot be used as a value because it was exported using
'export type'") across both entries; with the fix, zero.
This makes the already-merged docs on this branch true. The RN reference pages
write `import { ToolCallStatus } from "@copilotkit/react-native"` and
`status === ToolCallStatus.Executing` (useRenderTool.mdx:170, useFrontendTool.mdx:129,
useHumanInTheLoop.mdx:86) and `import { useAgent, UseAgentUpdate }` with
`updates: [UseAgentUpdate.OnMessagesChanged]` (useAgent.mdx:234). None of those
imports resolved before this commit.
AbstractAgent is a deliberate inclusion, not scope creep: it is a runtime class
and the AG-UI extension point consumers subclass, and @ag-ui/client is a
dependency of this package rather than a peer, so a consumer cannot reliably
import it from there directly. It carries no bundle cost — the headless entry
already imports @ag-ui/client transitively through
@copilotkit/react-core/v2/headless, and esbuild tree-shakes an unused
re-export, so scripts/measure-headless.mjs still reports 92.7 kB gzip.
Forced test change, in scope only because the fix causes it: the value
re-export makes headless.ts the first runtime importer of @copilotkit/core in
this package's graph, so `import "../index"` now evaluates real core, which
named-imports RUNTIME_MODE_SSE and friends from @copilotkit/shared.
headless-integration.test.tsx replaced that module wholesale with a two-key
factory, so the import threw. Fixed by spreading importOriginal() instead of
replacing — the form vitest's own error message prescribes — leaving
createLicenseContextValue the only stubbed member. No assertion, case or
coverage changed.
Call-Site Enumeration (Procedure 2 step 8) — `grep -rn` per symbol across
packages/ plus every importer of @copilotkit/react-native in the repo. Every
site holds, because type -> value is a widening: an `import type` of a value
export is still legal.
ToolCallStatus
packages/react-native/src/headless.ts:94 — the changed export. Holds.
No other site in packages/ or examples/ names it. Nothing imported it
before, which is the bug.
CopilotKitCoreRuntimeConnectionStatus
packages/react-native/src/headless.ts:95 — the changed export. Holds.
No other site.
CopilotKitCoreErrorCode
packages/react-native/src/headless.ts:96 — the changed export. Holds.
CopilotKitProvider.tsx:12,37 / CopilotChat.tsx:13,93 / CopilotPopup.tsx:26,223
— all `import type … from "@copilotkit/core"`, used only in a `code:` field
position. They import from core directly, not through this entry, and a
type position is unaffected by the re-export kind. Hold.
UseAgentUpdate
packages/react-native/src/headless.ts:65 — the changed export. Holds.
packages/react-core/src/v2/headless.ts:41 — already a value export there,
with a comment giving this exact reason; this commit makes RN agree with it
rather than diverge. Holds.
AbstractAgent
packages/react-native/src/headless.ts:107 — the changed export. Holds.
packages/react-native/src/__mocks__/test-copilotkit.tsx:22,41,42 — already
imports the class as a VALUE from @ag-ui/client and subclasses it, i.e. it
had to bypass this entry to do what the entry now permits. Unchanged and
still passing. Holds.
packages/react-core/src/v2/**, packages/channels-telegram/** — all import
from @ag-ui/client directly; none route through @copilotkit/react-native.
Hold.
Importers of @copilotkit/react-native outside the package
examples/v2/react-native/demo/{App.tsx,src/ChatScreen.tsx,index.js} — import
CopilotKitProvider, useAgent, useCopilotKit, useFrontendTool and the
polyfills entry. None of the five symbols appears anywhere in the demo, so
nothing to break; the demo is now able to import them. Holds.
Surface guards
src/__tests__/headless-entry-surface.test.ts — its `not.toHaveProperty`
denylist covers the chat/attachment exports and the two removed registry
symbols; none of the five is listed, and its bare-specifier bans
(@gorhom/bottom-sheet, expo-*, shiki/mermaid/katex/a2ui-renderer, non-headless
react-core entries) are unaffected by adding @copilotkit/core and
@ag-ui/client edges. Passes unchanged.
Verification: `pnpm nx run @copilotkit/react-native:check-types` succeeds
(tsc --noEmit, 0 errors). `npx vitest run --reporter=dot` — 22 files, 253
tests, all passing. `npx oxfmt --check` clean; `npx oxlint` 0 errors and 2
warnings, both pre-existing in the touched test file (no-shadow on a mocked
`React`, no-this-in-sfc).
Note on a pre-existing flake: headless-entry-surface.test.ts hits the 5000ms
default testTimeout on `await import("../headless")` when the machine is loaded.
Measured 6 serial runs each way on the same box — pre-fix headless.ts failed
4 of 6, post-fix 3 of 6, identical timeout signature — so it predates this
change and is load-induced, not caused by it. Every run above used
`--testTimeout=60000`; raising that default (or making those assertions
static) is worth a follow-up, and is not this commit's to make.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`toolMessages` rebuilt every tool message with
`content: typeof m.content === "string" ? m.content : ""`, so any non-string
content became `""` — indistinguishable from a tool that genuinely returned
nothing, with nothing logged. Renderers receive `result: string` and cannot tell
the two apart.
Static typing says the branch is unreachable: `ToolMessageSchema.content` is
`z.string()`, the SSE transport zod-parses every TOOL_CALL_RESULT before it
reaches `agent.messages`, and core stringifies non-string handler results itself
(`JSON.stringify(result)`, run-handler.ts:831/1014) before inserting the tool
message. So this is a defensive branch, not a live data-loss path — but core
keeps the same hedge (`normalizeToolResultContent` accepts `unknown` and unwraps
arrays of text parts), because unvalidated producers exist: restored thread
history, a non-SSE transport, and app code casting on `addMessage`. Rather than
delete the branch, make it loud and lossless: serialise non-string content the
way core already represents non-string results, and warn in dev (`__DEV__`
guard, matching src/CopilotChat.tsx and streaming-fetch.ts). null/undefined
still render as `""` — nothing to lose — but now warn instead of passing
silently. Never throws from the render path.
Call-Site Enumeration (semantics of ToolMessage.content in RN's map):
- Producer: the `toolMessages` memo, packages/react-native/src/components/CopilotChat.tsx.
Module-local const; no other module imports it.
- In-file consumers: the `extraData` memo (map identity only, never reads
content) and `renderItem`, which passes `toolMessages.get(tc.id)` to
`renderToolCall`.
- react-core: `useRenderToolCall`
(packages/react-core/src/v2/hooks/use-render-tool-call.tsx) forwards
`toolMessage.content` as `result` (:53) and compares it in the memo
comparator (:91-93). Both still receive a string.
- Downstream: app renderers registered through RN's `useRenderTool` ->
`useFrontendTool`, typed by `ReactToolCallRenderer` whose Complete branch
declares `result: string`. That contract is unchanged.
- Behaviour delta is confined to non-string content; the string path is
byte-identical, and `""` stays `""` and stays silent.
Tests: 6 cases in CopilotChatToolCalls.test.tsx cover verbatim strings, an
empty result staying empty and silent, array/object serialisation with one
warning, null warning, and non-serialisable content not throwing.
RN suite 259/259, check-types clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CopilotChat` derived both `toolMessages` (toolCallId -> ToolMessage) and
`listItems` from `useMemo(..., [messages])`, where `messages` is `agent.messages`.
That dependency never changes on the path the memos exist to serve, so the
headline fix of this PR was inert:
- Core inserts tool results by MUTATING IN PLACE — `agent.messages.splice(insertAt,
0, toolMessage)` (packages/core/src/core/run-handler.ts:931, :1080). Nothing in
production reassigns `.messages`; grepping for that assignment in
packages/core/src hits test files only.
- `AbstractAgent.addMessage` is a `this.messages.push(...)`, and AG-UI's apply
pipeline reassigns the SAME array object for the whole run, so identity changes
at most once per run and then never again.
- `useAgent` re-renders with a bare `forceUpdate()` on `onMessagesChanged`
(packages/react-core/src/v2/hooks/use-agent.tsx:385-397); it does not hand down
a new array either.
Net effect: both memos froze at whatever the first render of a run saw. Tool
renderers kept receiving `result: undefined` with a status that never reached
`complete`, and any assistant message or tool call appended mid-run never reached
the flat list at all (`listItems` only ever recomputed when `isRunning` flipped).
Both memos are now keyed on a lightweight content fingerprint — ids, roles,
content length, `toolCallId`, and tool-call ids plus argument lengths — mirroring
react-core's web `messagesMemoKey`
(packages/react-core/src/v2/components/chat/CopilotChat.tsx:983). Length rather
than value so large text and base64 attachment payloads are not re-serialized
every render, and the fingerprint is recomputed per render so typing in the
composer still does not rebuild the transcript.
Why 253 tests were green over this: every RN chat suite drives messages by
re-rendering `TestCopilotKit` with a NEW array, which DOES change identity, so
those tests pass regardless of the dependency. The two added tests mutate in
place through `agent.addMessage` instead — the same push core's paths bottom out
in — and fail against the pre-fix source:
AssertionError: expected 'inProgress:Rooftop' to be 'complete:Rooftop'
TestingLibraryElementError: Unable to find an element by: [data-testid="places"]
`TestCopilotKit` gains an optional `agentRef` prop to publish the stable agent so
a test can reach that path.
Call-Site Enumeration
- `TestCopilotKitProps` (new OPTIONAL `agentRef`; no existing site needs a change):
- packages/react-native/src/components/__tests__/CopilotChatToolCalls.test.tsx (10 uses)
- packages/react-native/src/hooks/__tests__/render-tool-call.integration.test.tsx (3 uses)
- no other `<TestCopilotKit` in packages/, examples/ or showcase/
- `messagesFingerprint`: new module-private helper, 1 definition + 1 call, both in
packages/react-native/src/components/CopilotChat.tsx. Not exported.
- No public RN export or `CopilotChatProps` field changed; `extraData` and
`renderItem` keep their existing shapes and pick the corrected values up
through their existing deps.
Verification: @copilotkit/react-native 255/255 tests in 22 files; `tsc --noEmit`
clean. Left untouched by design: the non-string tool-content coercion and
`extraData` omitting `listItems`, both owned by other changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The header claimed deriving `RenderToolProps` from `ReactToolCallRenderer` made
drift between RN and web impossible, and that `check-types` would catch any
divergence. Both halves are false as written.
react-core publicly exports its own `RenderToolProps<S>`
(src/v2/hooks/use-render-tool.tsx:9-36) which is generic over a schema, carries
arguments under `parameters` rather than `args`, and types `status` as the string
literals "inProgress" / "executing" / "complete" rather than as `ToolCallStatus`
members. RN's derived type differs from it in both the payload field name and
the `status` type, today, on the same branch that made the claim.
`check-types` cannot see that divergence: nothing relates the two types, and the
one place they meet — react-core's bridge at use-render-tool.tsx:178-186 —
spreads the enum-typed props into the literal-typed slot and compiles, because a
string-enum member is assignable to its own literal type. Web's public `status`
is a widening of the canonical contract, not a derivation from it.
Rewritten to claim only the defensible guarantee: RN's props cannot drift from
`ReactToolCallRenderer`, the contract renderers are actually invoked against.
The residual divergence from web's public type is now stated explicitly, along
with the fact that RN's entry point re-exports web's three `RenderTool*Props`
arms so both shapes ship under similar names. The trailing paragraph now names
the states as `ToolCallStatus` members, matching how the reference page
describes them.
Comment-only; no type declaration changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The header JSDoc on the new derived `RenderToolProps` claimed the deleted RN
type had "args unconditionally partial". The opposite was true: the old
`RenderToolContext.tsx` declared `args: T`, so RN promised the FULL argument
object at every status — the drift this refactor fixes is that the canonical
contract ADDS an `"inProgress"` state in which `args` narrows to `Partial<T>`.
Stating the drift backwards in the very file that defines the contract misleads
anyone reasoning about the migration, so the historical claims are now spelled
out concretely and verified against the deleted type:
- old `status` was `"executing" | "complete"` with no `"inProgress"` member
- old type omitted `name` and `toolCallId` entirely
- old `args` was unconditionally `T`, never `Partial<T>`
Comment-only; no type declaration changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Final review fix wave for the RN render-tool convergence branch.
Substantive:
- Extend packages/react-core/scripts/assert-headless-purity.mjs to also scan the
built /v2/context chunk (context.mjs/context.cjs), not just /v2/headless.
/v2/context carries CopilotKitCoreReact and is imported by react-native, so a
future shiki/mermaid/katex leak through it would bloat RN bundles (#4893) while
neither hard-fail guard fired. Comment and failure message updated to name both
RN-reachable entries. Mutation-verified against context.mjs.
- Document the closure-staleness convergence: render is now captured at
registration (passed into useFrontendTool) and only refreshed when deps change,
no longer re-read every render. Consumers whose render closes over changing
state must now pass deps. Documented in the useRenderTool JSDoc, the
useRenderTool.mdx reference, and the changeset migration notes.
Minor sweep:
- CopilotChat extraData now lists what renderItem actually reads
({ isRunning, renderToolCall, toolMessages }); drop unused executingToolCallIds.
- headless-type-exports.test-d.ts imports React explicitly instead of relying on
the ambient UMD global.
- useRenderTool.mdx migration heading no longer names the uncut 1.67.0 version.
- Changeset marks @copilotkit/react-core minor (new public type export), matching
its body.
Co-Authored-By: Claude <noreply@anthropic.com>
The RN headless entry now re-exports useRenderToolCall (render-tool
convergence), so the measurement's pending-state comment no longer applies.
Add the symbol back to the measured import surface and drop the TEMPORARY
note. Reported size is essentially flat (92.8 -> 92.7 kB gzip): the hook
reuses the renderer registry/resolver useRenderTool already pulls in.
Co-Authored-By: Claude <noreply@anthropic.com>
Assertions originally written by David McKay in PR #6346, re-driven through
CopilotKitCoreReact's registry instead of a mocked local one.
Co-Authored-By: David McKay <davidmckayv@users.noreply.github.com>
BREAKING CHANGE: useRenderToolRegistry and RenderToolProvider are removed.
Render tools now register into CopilotKitCoreReact.renderToolCalls, the same
registry react-core uses, so a React Native-specific registry and its provider
no longer exist. Use useRenderToolCall() to render a registered component
anywhere in your app, including non-chat surfaces, and delete RenderToolProvider
from your tree — CopilotKitProvider no longer installs it and nothing needs it.
Co-Authored-By: Claude <noreply@anthropic.com>
- SeededAgent.run now declares Observable<BaseEvent> return (via
ReturnType<AbstractAgent["run"]>) so check-types passes; throw-only body
avoids a runtime rxjs import the RN bundler cannot resolve.
- TestCopilotKit accepts optional executingToolCallIds.
- Restore executing-status, empty-string-args, and unrepairable-JSON-args
coverage as tests driven through the real CopilotKitCoreReact.
Co-Authored-By: Claude <noreply@anthropic.com>
The prior comment framed useRenderToolCall's absence from the size probe's
import list as a permanent design fact ("DOM-dependent … would fail the
build"). That is the same permanent-sounding rationale that let the stale
claim in src/index.ts go unrevisited for months. The export is missing only
because RN's headless entry does not export it yet; the render-tool
convergence on this branch adds it. Reframe the comment as TEMPORARY — REVISIT
so a future engineer restores the symbol and re-baselines the number once RN
headless exports it. Comment-only; measured size unchanged at 92.8 kB.
Co-Authored-By: Claude <noreply@anthropic.com>
react-native was missing from static_bundle_size.yml's package glob, so its
dist/ has never been measured despite being the consumer most exposed to the
#4893 regression (Metro does not tree-shake). This adds coverage:
- Extend the compressed-size-action glob to include react-native.
- New scripts/measure-headless.mjs: an esbuild-driven gzip signal for the
@copilotkit/react-native/headless entry, mirroring react-core's
measure-copilotchat.mjs (stdin + resolveDir, gzip sum, job-summary output).
- Wire a build + measure step into the copilotchat-import-size CI job.
First baseline: @copilotkit/react-native/headless = 92.8 kB gzip
(esbuild regression signal, not a Metro figure).
No limit fields (Phase 1 policy — see dev-docs/bundle-size.md).
esbuild added as a react-native devDependency (^0.27.0, matching react-core);
the root ">=0.25.4" override keeps the monorepo on a single esbuild (0.27.3).
Two corrections to the drafted script, verified by running it:
- Fed the entry via esbuild stdin with resolveDir=pkgRoot; a temp-dir entry
cannot resolve @copilotkit/react-native/headless through workspace node_modules.
- Dropped useRenderToolCall from the import list — the RN headless surface
deliberately does not export it (DOM-dependent; see src/index.ts).
Co-Authored-By: Claude <noreply@anthropic.com>