The packaged skills had grown into a second copy of the documentation.
`runtime` and `react-core` were roughly 60% transcribed API surface, and
most of their remaining "Common Mistakes" prose already existed on a docs
page. A cached copy of an API goes stale silently: four claims in the
deleted skills contradicted the source they cited, and one of them reached
a shipped PR before it was caught.
Replace them with two skills that look the answer up instead of restating
it:
- `copilotkit` — the four search tools and two explore tools of the
bundled `copilotkit-docs` MCP server, which corpus answers which
question, and the instruction not to answer from memory.
- `copilotkit-cli` — the CLI, led by `copilotkit verify --json`. Since
#1180 `verify` covers version skew, CORS, and transcription, which is
what most of the old `copilotkit-debug` skill described by hand.
Deleted: copilotkit-setup, copilotkit-develop, copilotkit-integrations,
copilotkit-debug, copilotkit-upgrade, copilotkit-agui, copilotkit-contribute,
copilotkit-self-update, and the three package-generated skills (react-core,
runtime, a2ui-renderer).
The `skills` directory is dropped from the `files` field of the three
packages that shipped one, so the tarballs no longer carry a copy.
`public-skill-drift.test.ts` guarded wording in files that no longer exist.
It is now a link guard: every `docs.copilotkit.ai` path named by a packaged
skill has to resolve to a page in this repo, and the two entry points have
to stay free of a transcribed API surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bridge-free /activity (review blocker): the `./activity` entry pulled ext-apps'
LATEST_PROTOCOL_VERSION through constants.ts, statically dragging the App SDK +
zod/v4 (~+55 kB gzip) into react-core's eager chunk. Move MCP_APPS_PROTOCOL_VERSION
to the bridge side (session.ts), sourced from the /app-bridge subpath the session
already imports - single source of truth, no hand-maintained literal, zero extra
cost. dist/activity.mjs and dist/constants.mjs now carry no @modelcontextprotocol
edge.
Keep react-core's zod peer at >=3.25 (review blocker): the floor moved down to the
package, it did not go away; ext-apps + the MCP SDK both hard-require it, so >=3.0.0
is an ERESOLVE install failure once an MCP App renders.
Drop the <copilotkit-mcp-app> web component from this PR: nothing consumes it here
(the React adapter builds its own iframe) and it carried two open defects. It lands
with the Vue/Angular adapters, its first real consumers. Removes its files, exports,
tsdown entry, and the now-unused lit dependency.
Also: add content.serverId to the bind effect deps; delete drained thread entries
from the request queue's maps (bounded growth); register the package in
static_compat.yml (+ a compat-check script) and static_bundle_size.yml; publint
repository.url -> git+https. Manifest regenerated.
Reduce MCPAppsActivityRenderer from a 786-line self-contained implementation to
a thin React adapter over the framework-agnostic bindMcpApp:
- the React shell owns the iframe (create / mount / size / remove) and wires the
session hooks (onResource, onSizeChanged, onError) to React state; all protocol
logic (AppBridge, sandbox proxy, request queue, ui/message + open-link, tool
input/result) now lives in @copilotkit/mcp-apps-renderer
- the bridge-free activity surface (MCPAppsActivityType, content schema, follow-up
runner) is re-exported statically from the package's new ./activity entry, so
registering the activity does not pull the ext-apps bundle
- bindMcpApp is loaded via a lazy import("@copilotkit/mcp-apps-renderer") inside
the effect, keeping ext-apps (~40-50 kB gzipped) out of the main chunk
Dependency move (FR-007): react-core drops its direct @modelcontextprotocol/ext-apps
dependency, the @modelcontextprotocol/sdk peer + devDependency, and the zod >=3.25
peer floor (reverted to >=3.0.0), and depends on @copilotkit/mcp-apps-renderer
instead. The package becomes the sole owner of the MCP libraries: sdk moves from a
peer to a dependency there (satisfying ext-apps' required sdk peer), and a
bridge-free ./activity subpath is exported.
Register the package in the monorepo release scope and regenerate the public API
manifest. react-core's MCP e2e suite (37 tests across 4 files) stays green;
check-types + oxlint clean; behavior-preserving.
- @modelcontextprotocol/sdk is now a non-optional peerDependency (matching how
ext-apps declares it) instead of an optional one. ext-apps ships as a direct
dependency and hard-peers the sdk, so the requirement is already inherited by
every consumer; the optional flag only hid that and dropped the install-time
signal. react-core does not use the sdk directly (type import only), so it
stays out of our dependencies; ext-apps remains the direct dependency.
- Guard the lazy bridge import with a try/catch that rethrows naming the packages
and the install command, so a missing or version-skewed peer surfaces as an
actionable error instead of an opaque module-resolution rejection in an effect.
- Load the ext-apps bridge lazily. AppBridge/PostMessageTransport now come from a
dynamic import() inside Effect 1, with a type-only import at the top. A
<CopilotKit> app no longer pays the ~40-50 kB gzipped ext-apps cost unless it
actually renders an MCP App. Verified: the built output has no static ext-apps
import, only import("@modelcontextprotocol/ext-apps/app-bridge").
- Move @modelcontextprotocol/sdk out of dependencies. It is now an optional
peerDependency (mirroring how ext-apps declares it) plus a devDependency for
our own build, so the sdk tree (express, hono, jose, ajv, cross-spawn) is no
longer an install/audit surface for every React consumer. Nothing in the
bundle reaches sdk at runtime; the only sdk usage is a type import.
- Raise the zod peer floor to >=3.25. The ext-apps/sdk schema slice imports
zod/v4 and zod/v4-mini, which only exist in zod >= 3.25; the old >=3.0.0 peer
let a consumer on zod 3.24 hit an unresolvable import at build time.
- Seed the host context at AppBridge construction (hostContext option) instead of
calling setHostContext after connect, so it is deterministically in place when
the widget's ui/initialize is handled (the seam #6689 needs to advertise
displayMode / availableDisplayModes at initialize).
- Restore the full cross-frontend testid surface-contract comment.
- Split the oncalltool guard so "no server hash" and "no agent" report distinctly.
Moves the published packages from 0.0.57 to the current AG-UI release across
@ag-ui/client, core, encoder and proto — 27 declarations in 18 packages.
0.0.59 is the first release carrying the subagent protocol surface
(SUBAGENT_STARTED/FINISHED/ERROR, subagentRunId) along with the null-omission
cleanup, so this is the dependency CopilotKit's subagent work needs.
Scope is packages/** plus the release script noted below. The examples and
showcases sit on a spread of older pins (0.0.40 through 0.0.58) and are left
alone.
One behavioural change comes with the bump. channels-core ships
sanitizeAgentEventStream because @ag-ui/client used to reject a TOOL_CALL_START
carrying parentMessageId: null — the shape @ag-ui/langgraph emits for an
interrupt-triggering tool call. 0.0.59 accepts that null and treats it as
absent, so the two tests asserting the run dies WITHOUT the sanitizer no longer
hold. They now assert the run survives, and the one at agent level still checks
the tool call actually arrives so it cannot pass vacuously. The sanitizer is
untouched and its coercion tests are unchanged; it is simply no longer the
thing keeping such a run alive.
The bump also broke the packed Angular consumer matrix. That job generates a
smoke app from scripts/release/lib/angular-package.ts, whose manifest restated
"@ag-ui/client": "0.0.57" as a literal while packages/angular moved to 0.0.59.
pnpm then installed both copies and the app failed to compile:
TS2322: Type 'SmokeAgent' is not assignable to type 'AbstractAgent'.
Types have separate declarations of a private property '_debug'.
The smoke app imports AbstractAgent directly, so it has to resolve the identical
copy the library ships against. Read that version off the packed manifest --
which verify-angular-package.ts already parses for the Angular support contract
-- instead of restating it, so no future AG-UI bump can desynchronise it.
Replace the hand-rolled MCP Apps host protocol in MCPAppsActivityRenderer with
the ext-apps host library, so the app<->host protocol is driven by the spec
package instead of string literals and a manual JSON-RPC router (the source of
prior drift: size-change vs size-changed, protocol-version mismatch, etc.).
- Add @modelcontextprotocol/ext-apps + @modelcontextprotocol/sdk deps.
- Per widget, connect an AppBridge over a PostMessageTransport to the sandboxed
iframe (existing sandbox proxy kept as the transport relay). The bridge owns
initialize/capability/version negotiation (protocolVersion 2026-01-26),
host-context, tool input/result, and the sandbox handshake
(onsandboxready -> sendSandboxResourceReady).
- Map app->host requests/notifications to CopilotKit behavior via bridge
handlers: open-link (window.open), tools/call (runAgent proxy), size-changed
(iframe sizing), initialized, logging. Preserve the runAgent queue and the
thread-capture semantics of issue #5819, and tool-input/result delivery.
- ui/message keeps CopilotKit's role/followUp extensions (the ext-apps schema
only allows role "user" and has no followUp) via a custom request handler:
extensions are read from params._meta.copilotkit first (preferred, forward
looking) and from the legacy top-level params.role/params.followUp
(deprecated). Add a test for the _meta channel.
- Remove the hand-rolled PROTOCOL_VERSION, send/response helpers, and the
JSON-RPC switch now owned by the bridge.
Tests: MCP Apps e2e updated to the bridge's behavior (open-link without url is a
schema-validation error surfaced by the bridge). react-core type-check green;
MCP Apps e2e green; validated end-to-end in a browser (widget renders,
initialize/tool-result/ui/message work).
## 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)
## 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)
`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>
`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>