The report names both /connect and /run. The run path reaches the
delegate through #runViaDelegate, which shares resolveDelegate with the
connect path, so the accessor fixes both — but that was inferred from the
shared call site rather than pinned. Verified failing against the
pre-fix file (sent tenant-a where tenant-b was expected).
`ProxiedCopilotRuntimeAgent` builds its `IntelligenceAgent` delegate once
and caches it for the proxy's lifetime, copying `headers` into the
delegate's constructor config. Nothing ever refreshed that copy, so a
header that changed later never reached `/connect` or `/run` — for the
life of the agent.
`setHeaders`/`applyHeadersToAgent` could not fix it: they write an
agent's `.headers`, and `IntelligenceAgent` exposed only `private
config`. `syncDelegate` looked like the refresh path but its `hasHeaders`
probe is `"headers" in agent`, which was false for the delegate.
Multi-tenant apps that carry the active tenant in a header saw the join
attempted under the previous tenant's identity with the new tenant's
thread id, answered THREAD_NOT_FOUND. A rotated `Authorization` bearer
has the same exposure. Only a full reload cleared it.
Expose `headers` as a public accessor pair backed by `config`. The
accessor is the entire fix: it makes `hasHeaders` true, so `syncDelegate`
— which already runs on every `resolveDelegate()` — starts actually
refreshing the delegate before each join. Note that changing
`requestJoinCredentials$` to read live headers, as the report suggested,
does nothing on its own: there was no live source on the class to read,
and without the accessor `this.headers` is `undefined`, which drops every
header. It reads through the accessor here for a single source of truth,
not because that read carries the fix.
The setter replaces the config object rather than mutating it, because
`clone()` shares the config reference. The join path alone would mask an
in-place write (syncDelegate rewrites headers just before every join),
but the credential re-acquisition inside a running pipeline does not
re-sync, so a clone's tenant could ride out on the original's
socket-error refresh.
`credentials` had the identical defect via `config.credentials`
(`hasCredentials` was false too) and gets the same treatment.
Verified beyond the unit tests by driving the real chain
(`CopilotKitCore.setHeaders` -> registry -> proxy -> delegate ->
outbound POST) in a plain Node process with only `fetch` stubbed:
before, `"headers" in delegate` was false and the join after a tenant
switch still sent tenant A; after, it sends tenant B.
Reported by Sameday against 1.67.1 with a deterministic staging repro.
## Problem
`@copilotkit/react-core` ships **two independent copies** of the v2
context module, so `useLicenseContext` imported from
`@copilotkit/react-core/v2/context` returns the default forever —
`status: null` even when `/info` reports `licenseStatus: "valid"`.
Reported downstream as a chat-history sidebar that never loads, because
`useThreads` is gated on license status.
`src/v2/context.ts` is compiled by two separate tsdown builds:
| Build | Output | Contains |
|---|---|---|
| `entry: ["src/index.tsx", "src/v2/index.ts"]` | `dist/` shared chunk |
inlined copy **A** |
| `entry: {context: "src/v2/context.ts"}` | `dist/v2/context.*` |
standalone copy **B** |
There is no import edge between them, so `createContext()` runs twice.
`CopilotKitProvider` lives in the shared chunk and publishes to **A**;
`@copilotkit/react-core/v2/context` exports **B**, which nothing ever
provides.
Verified against the published 1.66.4 artifact:
```
$ grep -n "createContext" dist/v2/context.mjs
104:const CopilotKitContext = createContext(null);
124:const LicenseContext = createContext({
$ grep -n "createContext" dist/copilotkit-nRjRp2_5.mjs # inside //#region src/v2/context.ts
1522:const CopilotKitContext = createContext(null);
1544:const LicenseContext = createContext({
$ grep -E '^import .*from "[^"]*context[^"]*"' dist/copilotkit-nRjRp2_5.mjs
# (empty — no import edge)
```
`CopilotKitContext` is duplicated identically, so `useCopilotKit`
imported from that subpath throws `"useCopilotKit must be used within
CopilotKitProvider"`. The subpath was effectively unusable for web
consumers; license was just the *silent* failure mode.
**Compounding defect:** `src/v2/providers/index.ts` enumerates its
exports by name and omits `useLicenseContext` (even though
`CopilotKitProvider.tsx:19` re-exports it). So the live copy had **no
public import path at all**, leaving consumers with no correct
alternative.
Not a 1.66.x regression — broken since c3c30969e4 (2026-05-06), the
commit that introduced the split.
## Fix
1. **`tsdown.config.ts`** — hoist the existing `externalize-context`
plugin and apply it to the `dist/` build. The headless build already
used it for exactly this reason ("ensuring a shared React context
instance at runtime"); it was simply never applied here. One instance
now. UMD builds stay self-contained by design.
2. **`src/v2/providers/index.ts`** — export `useLicenseContext`.
3. **`scripts/context-singleton-preflight.mjs`** *(new)* — build-time
guard, wired into `build`.
4. **`src/v2/providers/__tests__/providers-exports.test.ts`** *(new)*.
### Why a guard
This bug class is invisible to every gate we have. On the broken build,
`tsc`, 1471 vitest tests, `publint` and `attw` were **all green** while
the published package shipped two contexts — vitest imports *source*,
where only one module exists. The guard keys off the `//#region
src/v2/context.ts` banner tsdown emits per inlined module, and
self-checks: if that banner convention ever changes it fails loudly
rather than silently passing everything.
## Testing
**End-to-end reproduction against the built dist** — provider from
`/v2`, hook from `/v2/context`, exactly as a consumer app wires it. This
is the test that most directly encodes the reported bug.
Against a **pre-fix** build (rebuilt from the parent commit's
`tsdown.config.ts`):
```
× useLicenseContext sees server-reported 'valid', not the default
→ expected 'null' to be 'valid'
× useLicenseContext sees server-reported 'expired', not the default
→ expected 'null' to be 'expired'
```
That `'null'` is precisely the reported symptom — a valid license read
as `status: null`, permanently disabling license-gated features.
Against this branch:
```
✓ src/v2/__tests__/dist-context-singleton.test.tsx (2 tests)
```
It also confirms the self-reference resolves under a real bundler
(Vite), and it degrades to a loud skip when no dist is present (verified
by removing `dist/v2/index.css`): nx `test.dependsOn` is `^build`, so
this package's own build is not guaranteed to have run before `test`.
The hard gate is therefore the preflight, which runs as part of `build`.
**Both build-level guards proven red→green — not merely green.**
Preflight against the **actual published 1.66.4 dist** (expected fail):
```
$ node scripts/context-singleton-preflight.mjs .../copilotkit-react-core-1.66.4/dist
context-singleton-preflight: src/v2/context.ts is bundled into 2 unexpected file(s):
- copilotkit-nRjRp2_5.mjs
- copilotkit-sitn7Oe8.cjs
exit=1
```
Preflight on this branch's build (expected pass):
```
$ node scripts/context-singleton-preflight.mjs
context-singleton-preflight: OK — src/v2/context.ts bundled only into 4 allowed target(s).
exit=0
```
New export test with the fix line removed (expected fail):
```
× exports the provider hooks as runtime functions
→ useLicenseContext should be exported as a runtime function: expected 'undefined' to be 'function'
```
Emitted-bundle verification after the fix:
```
$ grep -c "checkFeature: () => true" dist/copilotkit-*.mjs # shared chunk no longer defines it
0
$ grep -o 'from "@copilotkit/react-core/v2/context"' dist/copilotkit-*.mjs | head -1
from "@copilotkit/react-core/v2/context"
$ grep -o 'require("@copilotkit/react-core/v2/context")' dist/copilotkit-*.cjs | head -1
require("@copilotkit/react-core/v2/context")
```
UMD must stay self-contained (own copy, no external import) — confirmed
unchanged:
```
dist/index.umd.js: ownCopy=1 externalImport=0
dist/v2/index.umd.js: ownCopy=1 externalImport=0
```
Full gates:
```
$ vitest run
Test Files 124 passed (124)
Tests 1475 passed (1475)
$ tsc --noEmit # exit 0
$ oxlint <changed files> # Found 0 warnings and 0 errors.
$ oxfmt # clean
$ publint . # clean (only pre-existing repository.url suggestion)
$ attw --pack . --profile node16
"@copilotkit/react-core" node16 CJS/ESM 🟢 bundler 🟢
"@copilotkit/react-core/v2" node16 CJS/ESM 🟢 bundler 🟢
"@copilotkit/react-core/v2/context" node16 CJS/ESM 🟢 bundler 🟢
"@copilotkit/react-core/v2/headless" node16 CJS/ESM 🟢 bundler 🟢
```
Bundle-size impact is negligible: `dist/v2/context.mjs` is 4.6 KB, and
the `bundle-size` / `copilotchat-import-size` CI checks both pass.
## Reviewer note — one behavioral trade-off
v1 and v2 share the emitted chunk, so `@copilotkit/react-core` (v1) now
**transitively depends on package self-reference**. I verified this
resolves under both ESM and CJS (above), and it's the same mechanism
`/v2/headless` already ships. Every `exports`-map-aware resolver handles
it, but a legacy `main`-only resolver (webpack 4) would not. Flagging
explicitly rather than assuming, since v1 is fully supported.
Avoiding it entirely would mean splitting v1 and v2 into separate
bundles, which duplicates the whole shared chunk — strictly worse. Happy
to take that route if we still support webpack-4-era consumers.
## Workaround for consumers on 1.66.4
```tsx
import { useCopilotKit } from "@copilotkit/react-core/v2"; // NOT /v2/context
export function useLicenseStatusCompat() {
const { copilotkit } = useCopilotKit();
const [status, setStatus] = useState(copilotkit.licenseStatus);
useEffect(() => {
const sync = () => setStatus(copilotkit.licenseStatus);
const sub = copilotkit.subscribe({ onRuntimeConnectionStatusChanged: sync });
sync(); // catch-up — /info may resolve before we subscribe
return () => sub.unsubscribe();
}, [copilotkit]);
return status;
}
```
The `sync()` catch-up matters: `useCopilotKit` registers its re-render
subscription in an effect with no catch-up read, and a provider-only
catch-up (`CopilotKitProvider.tsx:670-696`) won't re-render a
`useCopilotKit`-only consumer since `contextValue` doesn't change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A managed Channel that loses its gateway link was invisible outside the
host process: the only trace was the injected `log` seam, which for a
self-hosted or Railway-hosted runtime reaches nobody who can act on it.
One 2026-08-12 outage ran 2.7 days on `kite-community` before a customer
reported it, and reconstructing it needed pod forensics.
Emit `channel_session_dropped` (carrying the cause we already compute for
the log line) and `channel_session_recovered` (carrying how long the
outage lasted). A session may report `online` without a preceding drop,
which is not a recovery and is not reported as one.
Events deliberately omit the Channel name — it is a customer-chosen
identifier — and carry no message content. Capture is fire-and-forget
with failures swallowed, the same contract fireInstanceCreatedTelemetry
uses: telemetry must never break a live session.
Also replay the drop cause on each "still down" reminder. In prod those
lines read `still down after 233134s; Phoenix is retrying` with no cause
at all, so an operator had to find the first line, 15 minutes earlier, to
learn it was an HTTP 502.
## Summary
`packages/runtime` declares its own `"uuid": "^10.0.0"` dependency, but
nothing in the package's source actually imports it directly — id
generation in `@copilotkit/runtime` goes through `randomUUID()`
re-exported from `@copilotkit/shared`, which already depends on
`uuid@^11.1.0`. The unused v10 pin just adds an npm deprecation warning
for every consumer installing `@copilotkit/runtime`:
```
npm warn deprecated uuid@10.0.0: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
```
This bumps it to `^11.1.0` to match `@copilotkit/shared` and clears the
warning.
## Test plan
- [x] `pnpm --filter "@copilotkit/runtime^..." run build` — all
workspace dependencies build cleanly
- [x] `pnpm --filter @copilotkit/runtime run check-types` — no type
errors
- [x] `pnpm --filter @copilotkit/runtime run test` — 126 test files /
1746 tests passing
- [x] Confirmed no file in `packages/runtime/src` imports `uuid`
directly (grepped for both `from "uuid"` / `from 'uuid'` and
`require("uuid")` — zero matches)
- [x] Confirmed `pnpm-lock.yaml` now resolves `uuid@11.1.0` for this
dependency, which is not on npm's deprecated-versions list
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Closes#5911
Adds an optional `agentId` field to `AgentRunnerConnectRequest` so
custom agent runners can hydrate messages when the cache is empty. Also
passes the already-available `agentId` in `handleSseConnect` through to
`runner.connect()`.
## Changes
- `packages/runtime/src/v2/runtime/runner/agent-runner.ts`: Added
`agentId?: string` to `AgentRunnerConnectRequest` interface
- `packages/runtime/src/v2/runtime/handlers/sse/connect.ts`: Pass
`agentId` to `runner.connect()`
## Verification
- Runtime package builds successfully (`pnpm --filter
@copilotkit/runtime build`)
- The change is purely additive — the field is optional and does not
affect existing runners
## Summary
`CopilotRuntime` exposes an `onError` callback, but the v1-to-v2
delegation drops it before server request processing. Runtime failures
therefore can't provide the incoming request headers that applications
use to correlate errors with a user or tenant.
## Root cause
The legacy constructor retains the callback type, while delegated
runtime options omit it. Common, SSE, and Intelligence handlers consume
failures inside their own boundaries, before a generic endpoint hook can
reconstruct the legacy event.
## Changes
- Add one internal runtime reporter that snapshots incoming Fetch
request headers.
- Attach the configured legacy callback to the delegated runtime
instance.
- Route common, SSE, and Intelligence agent-run failures, including
standard `RUN_ERROR` events, through that reporter exactly once.
- Redact sensitive request headers (authorization, proxy-authorization,
cookie, set-cookie, x-api-key, api-key, and the CopilotKit public-key
header) before they reach the `onError` event.
- Preserve responses, stream close behavior, telemetry, cleanup,
logging, endpoint hooks, and callback isolation.
- Add production-path regressions.
## Compatibility
The existing `CopilotErrorEvent` type and optional
`context.request.headers` field remain unchanged. Header names and
values come from the failing request's Fetch `Headers` object, with
sensitive credentials stripped before the event is emitted. The callback
receives a fresh record, so mutation cannot alter the request or a later
event.
## Out of scope
React provider propagation, Chat, CopilotMessages, the deprecated
runtime-client hook, redaction policy, HTTP response exposure, v2
endpoint-hook semantics, and non-agent runtime routes remain outside
this slice.
## Related PRs and Issues
Addresses #2716.
Scope follows
https://github.com/CopilotKit/CopilotKit/issues/2716#issuecomment-5086936254.
Runtime error-routing precedent:
https://github.com/CopilotKit/CopilotKit/pull/2143.
## Test plan
- [x] Runtime error regression and reporter tests, 12 + 4 tests passed.
Covers public routing, sanitized header propagation with credential
redaction, callback rejection containment, snapshots, mutation
isolation, and malformed requests.
- [x] SSE and Intelligence telemetry tests, 7 + 14 tests passed. Covers
agent-run failures, `RUN_ERROR`, setup boundaries, exact-once reporting,
telemetry, cleanup, and response preservation.
- [x] Runtime preservation suites, 57 + 129 + 56 tests passed. Existing
request, endpoint-hook, and runtime-library behavior remains intact.
- [x] Typecheck, formatting, and lint passed on changed runtime files;
lint reported four pre-existing warnings.
- [ ] CI green (`static / quality`, `test / unit` on Node 20/22/24).
## What broke
The regression was introduced by commit fec70d086 (feat(angular):
checkpoint 2 - core and package), merged through PR #6109 as b07482da5.
That commit established Angular 20 as the package’s compiler and support
floor, but the demo remained on Angular 21 after 8b13fbcb7 (build:
update ng).
It also introduced the packed smoke runner without canonicalizing macOS
temporary paths, allowing /var/... and /private/var/... to resolve
inconsistently.
## Why I made this change
I moved the demo back to Angular 20 so it exercises the lowest supported
Angular version, aligned the Angular 20 dependencies and support
contract on
20.3.27, and canonicalized the packed consumer directory before starting
the SSR server.
This keeps the demo, package metadata, tests, and lockfile consistent
while making the packed smoke test reliable across symlinked temporary
directories.
## Changes
- Align the Angular demo with the Angular 20 support floor.
- Update Angular 20 dependencies and support-policy tests to 20.3.27.
- Resolve the packed consumer directory to its real path before
launching SSR.
## What does this PR do?
Fixes stale Copilot Runtime documentation links that still point to
`/concepts/copilot-runtime` and now route users to the existing
`/backend/copilot-runtime` page.
This updates both the source JSDoc and the generated reference MDX so
the current docs content and future regenerated reference docs stay
aligned.
## Related PRs and Issues
- Closes#2082
## Testing
- `rg -n "concepts/copilot-runtime" packages
showcase/shell-docs/src/content` returns no matches
- `rg -n "backend/copilot-runtime"
packages/runtime/src/lib/runtime/copilot-runtime.ts
packages/react-core/src/components/copilot-provider/copilotkit-props.tsx
showcase/shell-docs/src/content/reference/v1/classes/CopilotRuntime.mdx
showcase/shell-docs/src/content/reference/v1/components/CopilotKit.mdx`
- `git diff --check`
## Checklist
- [x] I have read the [Contribution
Guide](https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md)
- [x] If the PR changes or adds functionality, I have updated the
relevant documentation
- [x] "Allow edits by maintainers" is checked (lets us help iterate on
your PR directly — faster turnaround for everyone)
Slack's image block takes either an external `image_url` or a `slack_file`
pointing at a file that already exists in the workspace. The required-field check
demanded `image_url` unconditionally, so the `slack_file` form could not be built
at all — an image sourced from Slack itself was unreachable through the catalog.
An image now needs alt text plus *either* source. Passing neither is still an
error: the check moved, it did not disappear, and the test covers both halves.
Slack's option object is `{text, value}` — it has no `type` field, and neither do
confirm, option_group, conversation_filter, dispatch_action_config, slack_file,
trigger or workflow. The codec tagged every catalog entry with its manifest type
regardless, so each of those carried an unknown field and Slack refused the
entire message containing it.
That took out every select, multi-select, checkbox, radio group, overflow menu
and confirmation dialog authored through `Slack.Object.*` — the whole interactive
surface. Measured against a real workspace: 1 of 26 block elements was delivered
before this change, 23 after.
It went unnoticed because a refused payload produces no error. There is no log
line and no exception; the message simply never arrives, which looks exactly like
a bot that had nothing to say.
The existing catalog test asserted the very assumption that was wrong — that
every entry serializes its discriminator — so it was green while the product was
broken. It now asserts the corrected rule and guards against a silent relapse.
Two catalog errors, both surfaced by delivering every documented block into a
real Slack workspace rather than reading the reference again.
container serialized its children into `blocks`; Slack reads `child_blocks`, so
every container an app sent was refused — silently, because a refused payload
produces no error anywhere, just a message that never arrives.
file leaves the manifest. Slack states you cannot add it to app surfaces
directly; it only appears when *reading* messages that contain remote files, and
the same sentence appears verbatim in `@slack/types`' own doc comment. Keeping it
in an authorable catalog offered a component that could never succeed. Sending a
file remains thread.postFile(). alert stays out for the same class of reason
(modals only), and both exclusions now carry their citation so "missing" and
"deliberately not authorable" stay distinguishable.
`assert-headless-purity.mjs` resolved its dist directory with
`import.meta.dirname`, which landed in Node 20.11 and is `undefined` below it.
The root package.json declares `engines: { "node": ">=18" }`, so a contributor
or runner on Node 18 hit this hard-fail CI gate as:
TypeError [ERR_INVALID_ARG_TYPE]: The "paths[0]" argument must be of type
string. Received undefined
at Object.resolve (node:path:1115:7)
at .../scripts/assert-headless-purity.mjs:71:19
— a stack trace into node internals, at module load, that names neither the
gate nor the real problem. Reproduced against a real Node 18.20.8.
Switch to `path.dirname(fileURLToPath(import.meta.url))`, which both sibling
scripts in this CI job already use (react-core's measure-copilotchat.mjs and
react-native's measure-headless.mjs), so all three read the same and none of
them carries a hidden runtime floor its own package does not declare.
Verified under real Node 18.20.8: the script now walks all four entries (650 /
646 / 649 / 645 modules) and exits 0, and still exits 1 with the full
`links the heavy render stack` report when a forbidden dep is injected into a
dist entry. The metafile-driven graph walk, the loud failure on unresolvable
edges and all 17 negative tests are untouched (`test:scripts`: 19 pass).
Skill-staleness check (reskinnable-demo CLAUDE.md rule): not applicable — this
touches packages/react-core, nothing under .claude/skills/reskin/.
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>
`scripts/assert-headless-purity.mjs` is a hard-fail CI gate, and it did not do
what its header said. It read four built entry files and asked
`code.includes(dep)`. That is weaker than the claim in both directions, and every
item below was reproduced against a real build before this rewrite:
1. It never followed an edge out of those four files. Re-exporting one hook from
the fat `@copilotkit/react-core/v2` entry — which links shiki, mermaid,
cytoscape, katex and streamdown — left `dist/v2/headless.mjs` importing that
entry by name, and the gate printed "clean" for all four files, exit 0. Same
for a heavy dep reached through `@copilotkit/core`, which is external to this
build: the entry says only `from "@copilotkit/core"` and there is nothing to
grep. A split-out relative chunk escaped identically.
2. The header claimed the check "follows into node_modules". It followed nothing
— not node_modules, not a relative sibling chunk.
3. `code.includes(dep)` is unanchored, so it matched comments and strings. Not
hypothetical in either direction: the built artifact is comment-PRESERVING
(233 lines of block comments survive in dist/v2/headless.mjs), and the five
banned tokens sit in `src/v2/headless.ts`'s own banner. They are absent from
dist only because that module is a re-export shell whose banner attaches to no
retained code — moving the same sentence into a module that ships code
hard-failed CI on all five tokens while linking none of them.
The gate now drives esbuild with `metafile: true` over each built entry and
matches on the RESOLVED graph, so it follows relative chunk edges and into
node_modules for real, resolves `exports` maps, subpaths and pnpm symlinks, and
cannot be fooled or tripped by a comment. Matching is anchored at the package
name (`@shikijs/langs` and `cytoscape-fcose` count; `shikimori` does not) and
also covers a forbidden dep left external, which resolves to no graph input at
all. Unresolvable edges FAIL LOUDLY instead of reading as clean, as does a graph
that does not contain its own entry.
One edge shape survives a bundler: `import(name)` with a non-literal argument,
which esbuild leaves alone without even warning. For that the gate reads text —
the only place it does — over the graph's first-party files, using the
`stripComments` helper ported from the sibling RN guard so a documented
counter-example cannot trip it.
Adds `scripts/__tests__/assert-headless-purity.test.mjs` (17 tests, wired into
`test:scripts` next to measure-copilotchat's), because a hard-fail gate with no
coverage of its own failure mode is how this shipped. Proven after the fix: both
false negatives now exit 1, a clean build exits 0, and a banned token that
appears only in a comment exits 0.
esbuild is already this package's devDependency and already runs in the same CI
job, so the gate needs no workflow change.
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>
`headless-type-exports.test-d.ts` asserted nothing. Its only check was a
value-position annotation (`const inProgress: RendererProps = { … }`), which is
an assignability check, so degrading `RendererProps` to `any` produced zero
`tsc` errors. And `status` was pinned through a force-cast
(`"inProgress" as RendererProps["status"] & "inProgress"`), which collapses to
whatever the left side already is and suppresses the comparison outright.
Verified against the live divergence the guard exists to catch: changing
`ReactToolCallRenderer["render"]`'s `status` from the `ToolCallStatus` enum
members to bare string literals produced six errors in unrelated files and
ZERO in the guard file. Those six are incidental to this package — React
Native, the consumer this contract protects, has no such incidental users, so
on that side the drift would have been entirely silent.
Rewritten on `expectTypeOf` (already the type-assertion idiom here, see
`hooks/__tests__/use-agent-types.test.tsx`), with every positive assertion as
`toEqualTypeOf` — exact type identity, no assignability, no `as` casts. The
expected props union is spelled out independently of the type under test so the
comparison is a real detector rather than a tautology. Now pinned: the exact
props union, an explicit `not.toBeAny()` tripwire, the arm keys (`args`, not
`parameters`), and `status` as the enum in both directions.
Also pins the known divergence between react-core's two same-named public
types — the canonical renderer props (`args`, `ToolCallStatus`) and public
`RenderToolProps` (`parameters`, string literals) — as a change-detector, so
converging them becomes a deliberate, visible edit instead of silent drift.
Proven by re-applying each degradation and confirming `tsc` fails: `any` (4
errors), the enum → literal drift (3), `status` → `string` (2), `args` →
`parameters` (2), and export removal (TS2305). All proof mutations reverted.
Coverage note: this guard canNOT catch the RN `export type`-on-a-value bug.
That failure is invisible to `tsc` by construction, and it lives in react-native's
entry, which no react-core assertion can reach. It needs a runtime guard in
that package — react-core's equivalent is the sibling runtime test
`headless-exports.test.ts`.
The file is read by `tsc` only (tsconfig includes `src/**/*`); vitest's
`include` globs do not match a `.test-d.ts` basename and the package sets no
`test.typecheck`.
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>
Renders CopilotKitProvider from the built `/v2` entry and reads
`useLicenseContext` from the built `/v2/context` subpath — the exact
consumer wiring that was broken. On a pre-fix build it asserts
`expected 'null' to be 'valid'`, reproducing the reported symptom of a
license-gated feature never activating.
Skipped (loudly) when no built dist is present: nx `test.dependsOn` is
`^build`, so this package's own build is not guaranteed to have run.
The hard gate remains scripts/context-singleton-preflight.mjs, which
runs as part of `build`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`src/v2/context.ts` was compiled into two independent bundles. The build
that emits `dist/` (entries `src/index.tsx` + `src/v2/index.ts`) inlined
it into the shared chunk, while a second build emitted the standalone
`dist/v2/context.*`. Nothing linked them, so `createContext()` ran twice
and the package shipped two distinct React contexts.
`CopilotKitProvider` lives in the shared chunk, so it published to the
inlined copy. Anything importing from `@copilotkit/react-core/v2/context`
read the orphaned copy that no provider ever populated, and so saw the
defaults forever: `useLicenseContext().status` stayed `null` even when
`/info` reported `licenseStatus: "valid"`, permanently disabling
license-gated features such as `useThreads`. `CopilotKitContext` was
duplicated the same way, so `useCopilotKit` imported from that subpath
threw "must be used within CopilotKitProvider".
The headless build already externalized the module for exactly this
reason; the plugin was simply never applied to the `dist/` build. Hoist
it and apply it there too. The UMD builds stay self-contained by design.
Compounding this, `src/v2/providers/index.ts` enumerates its exports by
name and omitted `useLicenseContext`, so the live copy had no public
import path at all and consumers had no correct alternative. Export it.
Add a build-time guard, because this class of bug is invisible to every
existing gate: tsc, vitest (which imports source, where only one module
exists), publint and attw were all green while the published package
shipped two contexts. The guard fails against the real published 1.66.4
dist and passes on this build.
Broken since c3c30969e4 (2026-05-06), the commit that introduced the
split — not a 1.66.x regression.
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>