## Why
An Intelligence integration lost a request-to-row correlation map
partway through a user interaction — no error, no warning. It surfaced
as "our response routing is flaky". OSS-979 filed it as
`CopilotKitProvider` remounting its children.
The provider does nothing of the kind. It renders `{children}`
unconditionally at `CopilotKitProvider.tsx:952` — unkeyed, no early
return, and there is no `Suspense` boundary anywhere in `v2`. Nothing in
the SDK silently re-points the active thread either; every mutation path
(`setActiveThreadId`, `startNewThread`, the drawer row click, the
inspector override) is caller-driven.
The remount was app-side, and it was app-side because this skill told it
to be:
- `references/threads.md:98` teaches `useThreads()` → select →
`<CopilotChat key={activeId}>`, and that recipe is only reachable once
Intelligence is wired.
- `references/switching-agents.md:123` teaches "`key={activeAgent}`
forces remount so thread state doesn't leak" without saying what else
that discards.
- `examples/showcases/reskinnable-demo/src/app/[skin]/layout.tsx:223`
models `<SubagentActivityProvider key={threadId}>` above `{children}`,
commented "Remounting is deliberate".
Follow all three and you key a layout-level provider on a thread id that
changes asynchronously after mount. Everything below it dies
mid-interaction.
Two properties made it invisible:
- Durable threads exist only in Intelligence mode, so with a plain SSE
runtime `useThreads` returns nothing, the selected thread never changes,
and the remount never fires. It appears the moment Intelligence is
wired.
- Whether state survives depends on whether the user acted before the
thread list resolved.
## What changed
Docs only — no library change. Both traps now carry their blast radius,
in the four places an agent actually reads:
| File | Change |
|---|---|
| `SKILL.md` | Two invariants in the load-once section, so they land
before any reference is opened |
| `references/threads.md` | New HIGH entry on keying above app state;
note that `activeId` in the switcher recipe settles asynchronously |
| `references/switching-agents.md` | Existing HIGH entry now states the
blast radius and cross-links the threads trap |
| `references/switching-agents-recipes.md` | Key rule amended — keep it
on `<CopilotChat>`, nowhere higher |
| `references/agent-access.md` | The second route to the same symptom:
`useAgent` swaps a provisional stand-in for the real agent when `/info`
resolves, so an effect keyed on `agent` re-runs once, mid-interaction.
Adds an `isReady` pattern and a HIGH entry |
`isReady` appeared in **zero** shipped skills before this — it was
documented only in `showcase/shell-docs/.../useAgent.mdx` and in JSDoc.
Same shape as OSS-888, where the root cause was the shipped skill rather
than the library.
Also corrects a factual error: the skill claimed `useAgent` returns `{
agent }` only. It returns `{ agent, isReady }`.
The 10-file diff is 5 source files under `packages/react-core/skills/`
plus their 5 mirrors under `skills/`, regenerated with `pnpm
sync:plugin-skills`.
## Verification
- `pnpm check:plugin-skills` — mirror in sync
- `pnpm exec vitest run scripts/__tests__/sync-plugin-skills.test.ts` —
12 passed
- `oxfmt --check` — clean over both skill trees
- Full pre-commit suite green, including `test-and-check-packages`
(`test`, `publint`, `attw` across 2 projects and 20 dependent tasks)
## Not in scope
Whether the run's app keyed on `threadId` or on `agent` is not
settleable from the repo — its source is not in any checkout, and there
is no `2026-08-25` strands run report under
`tools/one-prompt-development/evaluation/runs` on any branch. Both
variants produce the reported symptom and this covers both, so a
first-hand repro is a separate task. The `reskinnable-demo` layout is
left as-is deliberately: it is a legitimate use of the pattern, and it
is now the worked example the guidance warns about.
Scoping detail in the OSS-979 comment.
refs OSS-979
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- place the thread-title task after the embedded conversation transcript
- explicitly tell the reused agent not to answer the conversation
- add a regression test that locks the prompt ordering
## Why
LangGraph starter agents can interpret the final embedded `user:` line
as the active request when the transcript comes last. They answer the
conversation instead of returning title JSON, causing retries and
eventual `Untitled` thread names.
## Validation
- reproduced with the latest LangGraph starter and
`@copilotkit/runtime@1.69.2`
- original prompt: 0/12 direct calls returned title JSON; 4/4 targeted
threads fell back to `Untitled`
- reordered prompt: 12/12 direct calls returned title JSON; 4/4 targeted
threads received generated titles
- runtime thread-name unit suite: 28/28 passing
- pre-commit affected package checks passing under the repository Node
22 toolchain
One "@bot say hi" in a channel produced about fifty identical replies in
eight seconds, and the same shape would hit any Channels app on Slack.
`message_changed` does not mean somebody edited the text. Slack also sends
it when a reply lands in the message's thread, when a link unfurls, and
when attachments are added. Every one of those carries a fresh
`revisionId` — which is exactly what the engine's inbound dedup keys on,
deliberately, so that a real edit is never swallowed as a duplicate. So
each announcement arrived as a fresh mention and started another turn: the
bot answered, the answer revised the parent message, and the revision
asked the same question again.
A revision is now a turn only when its text actually differs.
`previous_message` is the direct evidence where Slack sends it; where it
does not, `edited` stands in, since Slack stamps that only for a genuine
edit and the metadata-only revisions have neither. Edited mentions still
reach the app, deletes are untouched, and the normalizer stays pure — so
this also covers the Intelligence-side webhook ingress that shares it.
Found running an OpenTag Composio integration against a live Slack
workspace; instrumenting one mention showed eleven turns, ten of them
revisions of the same message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An Intelligence integration lost a request-to-row correlation map partway
through a user interaction, with no error and no warning. It surfaced as
"our response routing is flaky". OSS-979 filed it as CopilotKitProvider
remounting its children.
The provider does nothing of the kind. It renders `{children}`
unconditionally, unkeyed, with no early return and no Suspense boundary
anywhere in v2. The remount was app-side, and it was app-side because this
skill told it to be:
* `references/threads.md` teaches `useThreads()` -> select ->
`<CopilotChat key={activeId}>`, and that recipe is only reachable once
Intelligence is wired.
* `references/switching-agents.md` teaches "`key={activeAgent}` forces
remount so thread state doesn't leak" without saying what else that
discards.
* `examples/showcases/reskinnable-demo/src/app/[skin]/layout.tsx:223`
models `<SubagentActivityProvider key={threadId}>` above `{children}`,
commented "Remounting is deliberate".
Follow all three and you key a layout-level provider on a thread id that
changes asynchronously after mount. Everything below it dies
mid-interaction.
Two properties made it invisible. Durable threads exist only in
Intelligence mode, so in OSS-only development the selected thread never
changes and the remount never fires. And whether state survives depends on
whether the user acted before the thread list resolved.
Both traps now carry their blast radius, in the four places an agent
actually reads:
* `SKILL.md` -- two invariants in the load-once section, so they land
before any reference is opened.
* `references/threads.md` -- a HIGH entry on keying above app state, plus a
note that `activeId` in the switcher recipe settles asynchronously.
* `references/switching-agents.md` and `switching-agents-recipes.md` --
keep the `key` on `<CopilotChat>`, never on a wrapper or a layout
provider.
* `references/agent-access.md` -- the second route to the same symptom.
`useAgent` swaps a provisional stand-in for the real agent when `/info`
resolves, so an effect keyed on `agent` re-runs once, mid-interaction.
Adds an `isReady` pattern and a HIGH entry. `isReady` appeared in zero
shipped skills before this; it was documented only in shell-docs and in
JSDoc.
Also corrects a factual error: the skill claimed `useAgent` returns
`{ agent }` only. It returns `{ agent, isReady }`.
No library change. The provider behaves correctly; the guidance did not
describe what it costs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Intelligence connect handler classified a handful of platform rejections and
flattened everything else into HTTP 404 "Connect plan not available", writing the
real cause only to server-side stderr.
That made every unrecognised failure look like a missing thread. A 500 from
app-api, a socket timeout, a connection reset and a bug in our own code all
produced the same misleading answer, and the only way to find out what actually
happened was to read the runtime container's logs.
It cost a customer a day. Their Redis filled, app-api returned 500 on a
join-code write, and their engineer saw a 404 naming a "connect plan" that had
nothing to do with the failure. The platform was up and the request was
retryable; the reported status said neither.
Now:
- a status we already special-case (400, 401, 403, 404, 409) still reports as
a rejection with its message, unchanged;
- any other status from the platform passes through as itself, so a 503 stays
a 503 and the caller knows to retry; and
- an error carrying no status never reached the platform, so it reports 502
rather than 404, which says "the thing behind me is unreachable" instead of
asserting the thread does not exist.
Every branch now returns the underlying message rather than burying it in a log
line the caller cannot see.
The SSE run path in handlers/shared/sse-response.ts has the same shape and is not
addressed here: it returns 200 and text/event-stream before the run starts, so a
later throw closes the stream with no events and no error frame. That needs its
own change to the streaming contract.
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).
A recovery re-sync merged: it added and updated what the runtime reported
and never removed anything. That was safe but too blunt — an agent the
developer genuinely deleted stayed visible until a page reload, so the
application kept offering something that no longer existed.
The harm was never removal; it was removing the agent a live conversation
hangs on. Recovery now reconciles instead of merging, dropping an agent
only when both conditions hold: the runtime reported at least one agent
(an empty list is a runtime that has not finished registering, and
nothing it says is worth acting on), and the agent carries no
conversation state — no messages, and no thread bound to it by a
binding. Both are decidable inside the core, the second off the agent
instance, so nothing here depends on the UI layer.
Note that `threadId` is always populated: `AbstractAgent`'s constructor
generates one when none is supplied, and the run pipeline never writes
it. So presence says nothing, and the registry records the value it
minted each remote agent with in order to recognise the value having
moved — which is what a binding resolving a thread actually looks like.
Non-recovery re-syncs are unchanged: a deliberate runtime url, transport,
header or credentials change and the initial handshake all still replace
the set outright, conversation state included.
This is only safe because `subscribeToAgent` now leaves a live
subscription alone when handed the same instance. Pruning changes the
set, so recovery announces a change, so the state manager re-subscribes
per agent — and recovery fires while the run whose response proved the
runtime was back is typically still streaming. Covered by a test that
prunes across an in-flight run; it fails if that guard is removed.
It records whether the silence report caused a confirmation check, not whether
the timer fired, and the two are now deliberately different. One honest name
beats a doc comment explaining that the name is not quite right.
Five comments justified routing thread requests through the instrumented fetch
by saying it lets opening a view restore the status after an outage. It does
not: every binding withholds its thread requests until the status is already
connected, so while it is red nothing is sent. The justification is DETECTION
only, which is what the CopilotChat site already said correctly.
Also:
- Documents both meanings of the Error state and the invariant behind them —
the status reports the last actual contact with the runtime — on the
connection-status reference page, which described only the startup meaning.
- Renames RUNTIME_PROBE_TIMEOUT_MS to ɵRUNTIME_PROBE_TIMEOUT_MS. core/index.ts
re-exports agent-registry wholesale, so a constant whose own doc says
"exported for tests" was public API of @copilotkit/core.
- Guards the Inspector's read of ɵruntimeFetch the way it guards its four other
internal core accessors. A newer Inspector against an older pinned core was
handing the thread store `undefined`, which breaks the Threads view outright
rather than merely losing detection through it.
- Corrects the stop-request comment, which claimed to be the only runtime
destination off the seam; the suggestion route's stateless path, the memory
store and /inspector-metadata are too, just not by design.
- Corrects OSS-904-VERIFY.md, which said scenario 4 had real traffic to work
with and left it off the not-covered list.
Each of these passed the whole core suite with the production code
deliberately broken; each new test was verified by re-applying the mutation.
- The watchdog becoming a repeating interval: nothing kept the clock running
past the first fire, so a fresh probe every ten seconds for as long as one
request hangs went unnoticed. Pinned by count and by getTimerCount.
- Deleting the "only probe while Connected" guard: a developer pressing Send
three times at a red indicator bought three extra probes and three duplicate
wiring errors.
- Rerouting the stop request onto the seam, which would make pressing Stop
against a dead runtime turn the status red.
- Putting the agent-level auto-detect /info back on the global fetch. Both new
suites pinned "rest", so the product default was never exercised.
- "A success on a non-critical request still counts", documented and asserted
nowhere.
- isAbortError's string branch was unreachable from either call site, so it is
removed rather than tested; suggestion-engine's own isAbortError already
answers false for a string.
Also records where the absence tests' ten-minute virtual window stops proving
anything, and why the getTimerCount assertion is the period-independent one.
Two fixes that the same test sequence exercises.
Recovery skipped onAgentsChanged for an unchanged agent set, to avoid core
re-subscribing the state manager and revoking the in-flight run's
subscription. That was the wrong lever: when an agent HAD been added — the
usual reason for restarting a runtime — the notification fired anyway and the
recovering run's state was lost, while an unchanged-set check that stopped
working would silently swallow a genuinely new agent. The subscription hazard
is now fixed at its source in state-manager, so recovery announces its set
like every other connection attempt.
hasLiveRuntimeKnowledgeToProtect required the status to be exactly Error, but
recovery deliberately passes through Connecting, so a setRuntimeUrl or
setRuntimeTransport landing in that window wiped the agents, closed the
submission gate and stranded the application red. The condition is now stated
in terms of the thing being protected: remote agents exist, and contact is not
currently established.
Also rewrites the two diagnosis assertions, which were pinned on a status code
interpolated into both branches and a phrase matching neither, so both halves
passed against the wrong branch.
subscribeToAgent unconditionally tore down and revoked the subscription for
an agentId before installing a new one, including when it was handed the very
instance it was already subscribed to. The ag-ui pipeline captures its
subscriber list at run start, so the replacement is silently ignored and a run
that is still streaming loses its state and messages for the rest of the run.
Callers re-announce an unchanged agent routinely: a header change, a transport
change, an /info re-settle. Revocation now happens only when a DIFFERENT
instance takes over the id, which is the case the invariant was written for.
The watchdog set its "already reported" flag before calling the outcome
handler, which discards the report when a probe is already in flight, when
the status is not Connected, or when there is no runtime url. The flag then
claimed a report had happened and the request's own failure short-circuited
on a check that never ran, leaving the status green against a dead runtime.
The handler now reports back whether it actually started a check, and the
flag is set from that. A failure arriving after a check came back healthy is
new information, not part of the burst that check answered.
The watchdog reports silence and then suppresses the same request's eventual
FAILURE, so that one hang cannot buy two probes. Its eventual SUCCESS must
still count: the watchdog never cancelled anything, so a runtime that was
only very slow does answer, and that answer is contact — the one thing that
ends an outage. Widening the suppression to cover it would strand the status
at error with a working runtime.
The cooldown armed only after a CONFIRMED-UNREACHABLE verdict, so with the
in-flight latch collapsing simultaneous failures and the "only probe while
Connected" guard swallowing everything while red, the single case it still
suppressed was a runtime flapping error -> connected -> failing again inside
the window. In that case the window is the damage: the second outage leaves
no trace, the status reads connected, and nothing is emitted.
Burst collapsing is unaffected and stays asserted — it is the in-flight
latch's job, and several simultaneous failures still cost exactly one probe.
The test that pinned absorption is replaced by its inverse: a crash-looping
runtime now gets a check per incident. Two tests derived their waits from the
constant and no longer need to; removing the wait also takes 2.5s off the
suite.
Also moves RUNTIME_REQUEST_WATCHDOG_MS out of agent-registry, which
core/index.ts re-exports wholesale, into utils/runtime-request, which nothing
re-exports — so the watchdog constant stays off the package index and this
branch's net public surface is one export smaller than before. Corrects the
probe-timeout comment that still claimed to be the only timer here.
The whole mechanism reacted to the OUTCOME of a request, so a runtime that
accepts the connection and never answers produced no outcome at all: nothing
was reported, no confirmation probe was started, and the status stayed green
indefinitely. Reproduced against the demo — a server that accepts TCP and
never writes, a message sent, System Health still reading "Healthy /
Available / Ready" after 35 seconds with the request still pending in the
network panel. The probe's own bound does not help; it bounds a probe, and
here no probe is ever started.
This is the common shape of the failures this feature names. Only a stopped
dev server refuses fast — a container mid-restart, a half-switched deploy and
a dropped tunnel all hang.
The instrumented fetch now arms a per-request watchdog. If no RESPONSE HEADERS
arrive within RUNTIME_REQUEST_WATCHDOG_MS the silence is reported as the same
suspected outage a failure reports, which runs the ordinary probe. It watches
headers, not the body, so an agent that thinks for a minute is untouched: the
fetch resolves on the response head and the SSE body streams afterwards.
The watchdog observes and never interferes. It does not abort or cancel the
request, so a merely slow runtime still completes the user's run and still
reports its eventual outcome. That outcome cannot buy a second probe if the
watchdog already reported for the same request — the in-flight latch and the
"only probe while Connected" guard do not cover that on their own, because a
probe that came back healthy releases both while the request is still open.
It does not arm during server rendering, nor for a request the caller declared
non-critical, nor for one the caller already bounds with its own timeout: such
a request cannot go silent, which is the only gap this fills, and a shorter
bound of ours would override the budget the caller chose. The timer is always
cleared when the request settles — the package has no disposal path.
The caller-timeout fix works by telling two AbortErrors apart, so the
exclusion it must not weaken deserves its own case: the same hung runtime,
the same abort, but torn down by the caller rather than by its own clock —
and no check is issued.
The guard added with the rest of the fix round declined to paint the status
red whenever a success had landed after the attempt started. That is right
for the recovery re-sync, which has a follow-up attempt queued for exactly
that success — and wrong for a configuration change made from the red
state, which has nothing queued: the attempt returned without settling
anything and the status stayed at `connecting` for the rest of the page's
life. Found by working the case through rather than by a failing test, so
the test came first and reproduces it.
The three recovery-only behaviours (never remove an agent, do not
re-notify an unchanged agent set, let a later success overtake the verdict)
now hang off one `recovery` option instead of two flags that only ever
travelled together.
The ordering test is also sharpened. It asserted the final status, which
the queued follow-up made green either way — so it passed with the guard
deleted. It now asserts what a reader actually sees: no error status
notification and no wiring error for a runtime that has demonstrably been
answering. Verified: deleting the guard turns it red.
Three gaps the reviewers named, all of them behaviour the safety argument
already depends on.
The submission gate through the error state had no automated test in any
binding, though it is the third of three decisions that hold each other up:
if the red state closed the gate, no successful request could be issued and
only a page reload would leave it. The new react-core test drives the real
provider, the real core and the real submit path against a runtime that
goes away mid-session and asserts the state is left through the user
interface. Verified against a mutation that reuses the destructive startup
failure path mid-session: the test goes red.
A mid-session status round trip in a mounted tree was flagged as reasoned
rather than measured. Measured now: the run-activity effect lists the
status in its dependencies, so it does tear down and re-establish, but a
user-initiated run in flight is neither detached nor reconnected, and the
run-activity subscription is back once the status returns. When this chat
owns its run-activity store the round trip does restart it, re-issuing the
thread list and subscribe requests — documented rather than changed: it is
paid on a transition caused by user activity, not while idle.
Both connection-health suites pinned "rest" while the product default is
"auto". Core now covers rest, single and auto; the Intelligence suite
covers a runtime negotiated over the single-endpoint transport.
`applyRuntimeFetchToAgent`'s own comment claimed parity with
`applyHeadersToAgent`, but it was missing from `initialize`,
`addAgent__unsafe_dev_only` and `setAgents__unsafe_dev_only`. Worse,
`ProxiedCopilotRuntimeAgent` re-resolved its own runtime mode through a
bare global fetch, so an agent registered before the startup handshake
landed — the `registerProxiedAgent` case — asked `/info` off the seam and a
dead runtime stayed green. Those calls now go through `this.fetch`.
The stop request in `abortRun` stays on the global fetch and now says so:
it exists only because the user pressed Stop, and a user-initiated stop is
explicitly excluded from triggering a check. The comment and the code
agree either way.
Thread request timeouts were laundered into "cancelled". `threadFromFetch`
aborted its own controller when the store's 15s rxjs timeout unsubscribed,
and the resulting AbortError is indistinguishable from the user pressing
Stop, so a hung runtime produced no check at all. It now owns its timeout
and marks the request as caller-timed-out; genuine stop/unmount
cancellation, which still arrives via `signal` or unsubscription, is
classified exactly as before.
Callers can now mark a request non-critical, which the realtime-metadata
credentials route does: it is non-fatal by design, and older runtimes
refuse it outright because they do not offer the feature. Neither is news
about the runtime's health. A success on such a request still counts —
the runtime demonstrably answered.
The marker rides on `RequestInit`, which `fetch` ignores, so nothing
reaches the wire and no public API is added.
Four defects found in review, all in cases the harness could not express
because it modelled only a runtime that refuses.
The probe had no timeout and never released its latch. Against a runtime
that accepts the connection and never answers — a container mid-rollout, a
half-switched deploy, a dropped tunnel, i.e. this feature's own motivating
list — it never settled, every later failure short-circuited on the latch,
and the status stayed green indefinitely: the original bug, restored in
full. It is now raced against a bounded timeout that also aborts the
request, the latch is released in `finally` under a token so an abandoned
probe cannot clear a newer probe's latch, and a runtime url or transport
change abandons an in-flight probe outright.
The cooldown armed in that same `finally`, so it armed after a probe came
back HEALTHY — opening a window in which a genuine outage was dropped with
no probe, no log and no error. It now arms only on a confirmed-unreachable
verdict, and has tests either way; it had none at all before.
A success landing while a failed recovery re-sync was still notifying
subscribers collapsed onto that already-dying attempt, issued no `/info`,
and was lost — the chat visibly worked while the indicator stayed red for
the rest of the page's life. Recovery now queues instead of collapsing.
The in-flight comment claiming the two cannot overlap was wrong and is
corrected.
The recovery re-sync checked only `inspectorMetadataConnectionGeneration`,
which a successful runtime request does not bump, so a slow failing
`/info` could paint the status red after later runs had already been
answered. It now applies the same generation rule the probe does.
Also from the review:
- A configuration change made while the status is red reached the
destructive path, wiping the agents, emptying the conversation and
closing the submission gate — with no way back, since `connectRuntime()`
only fires from `Disconnected`. That is a fourth site in the PRD's
three-way safety argument and it now preserves.
- Recovery no longer removes agents. A runtime that is only part-way back
answers truthfully that it is alive while listing few or no agents, and
believing that report does the exact damage this design exists to
prevent, through the success branch. Removal stays correct on a
deliberate configuration change and on a fresh page load. With nothing
being removed, an unchanged agent set no longer re-notifies
`onAgentsChanged` — which was revoking an in-flight run's state-manager
subscription mid-stream, since the re-sync runs while the run's response
stream is still open.
- "Did not answer" and "answered, but refused" keep the same status and
the same error code, and differ in wording and diagnostic context.
Telling the reader a runtime that answered 401 is "unreachable" sends
them to check addresses, ports and containers while the cause is a
credential.
takeRuntimeDown() modelled only a runtime that REFUSES, which is why every
defect at the edges of this feature survived the suite: a runtime that
accepts the connection and never answers, and one that answers /info with a
different or empty agent list, could not be written down at all.
The harness now covers both, independently for /info and the run routes,
and knows a second runtime url and the single-endpoint transport so a
configuration change can be exercised.
The two absence tests move to fake timers installed before the core exists
and advance ten minutes instead of observing a real 2.5s window. Verified
against a mutation: a 5s retry loop injected into the unreachable path is
now caught (123 requests observed where 3 are allowed) where the previous
real-timer window saw nothing.
RUNTIME_PROBE_COOLDOWN_MS is exported so waits derive from it.
Thread REST calls go to the runtime, so under the destination rule they
are runtime traffic — but every binding injected the global fetch, so
their outcomes never reached the connection status. The practical effect:
with a dead runtime the Threads view left the status green, and after the
runtime came back, opening the Threads view could not clear it. Only
sending a message could.
React (both the useThreads store and CopilotChat's standalone
run-activity store), Vue, Angular and the Inspector's own owned store now
take `copilotkit.ɵruntimeFetch` instead. It is a pass-through and is
memoized per core, so nothing changes in the healthy case and no extra
request is issued.
Each binding's thread suite asserts the injection at the seam rather than
inferring it, since a regression back to the global fetch is invisible
from the rendered result.
Intelligence mode never touches the HTTP run route: it asks the runtime
over HTTP for realtime join credentials and then talks to the realtime
gateway over a websocket. That HTTP request used the global fetch, so it
bypassed the instrumented seam entirely and an Intelligence-mode
application still could not notice a runtime that went away — the one
case the destination rule ("a request counts if it goes to the runtime,
whatever issued it") was chosen to cover.
IntelligenceAgentConfig gains an optional `fetch`, defaulting to the
global one, and ProxiedCopilotRuntimeAgent hands its own request function
to the delegate it builds. Clones inherit it on both paths: the proxy's
clone already carries `fetch`, and IntelligenceAgent.clone passes the
whole config.
The websocket to the realtime endpoint deliberately stays out of it. It
is a separate service that can fail while the runtime is perfectly
healthy, and reporting "runtime unreachable" about a working runtime
costs more debugging time than no signal at all.
runtimeConnectionStatus was written once by the startup /info handshake
and never again, so a runtime that died after page load kept reporting
"connected" until a reload: System Health, the launcher error signal and
customer onError handlers all said healthy while nothing worked, and the
failure surfaced only as a failed agent run.
Every proxied runtime agent now routes its requests through an
instrumented fetch that reports each outcome. A failed request asks the
runtime once, directly, whether it is there (reusing the /info request,
never the startup connection path); if it is not, the status moves to
error, subscribers are notified and RUNTIME_INFO_FETCH_FAILED is emitted
with the runtime url, alongside the run failure. A successful request
moves it back and re-runs the full handshake so agents and version are
reconciled. There is no timer, no polling and no retry loop; a 2s
cooldown collapses a burst of failures into one probe, and a generation
counter keeps a late verdict from overriding an earlier success.
Both the inbound transition and a failed recovery re-sync preserve every
piece of runtime knowledge. Conversation state lives on the agent
instance and submission is gated on an agent being bound, so discarding
the agents would empty the chat and close the only path back out of the
error state.
The run handler matched four abort signals inline while the agent module
kept its own name-only predicate. Extract the superset (the AbortError
name plus the fixed message strings) into utils/abort-error.ts so every
place that classifies a failed request agrees on what a cancellation is.
## Problem
`packages/web-inspector/src/__tests__/launcher-error-signal.spec.ts` is
failing intermittently on `test / unit`, across unrelated branches and
on `main`. It is currently red on the **v1.69.1 release PR**.
```
FAIL src/__tests__/launcher-error-signal.spec.ts >
the whole gesture completes on its own and leaves the resting state behind
AssertionError: expected 'opening' to be 'closed'
```
| When | Branch | Shard | Run |
|---|---|---|---|
| 19:29Z | `release/publish/monorepo/v1.69.1` | Node 24 / React 18 |
[32886136479](https://github.com/CopilotKit/CopilotKit/actions/runs/32886136479)
|
| 14:09Z | `ben1/oss-924-agui-core-058` (#6687) | Node 24 / React 19 |
[32857130830](https://github.com/CopilotKit/CopilotKit/actions/runs/32857130830)
|
| 12:41Z | `lukas/oss-903-presentation-…` | Node 20 / React 18 |
[32848616256](https://github.com/CopilotKit/CopilotKit/actions/runs/32848616256)
|
Neither #6687 (an `@ag-ui/core` version bump) nor the presentation
branch touches gesture timing, and no shard fails consistently — it
follows runner load, not code.
## Cause
Both real-timer tests in this suite asserted a *pre-beat* state 200ms
after breaking the connection:
```ts
const context = await setup({ realTimers: true });
await context.breakConnection();
await context.advance(200);
expect(pillPhase(context.inspector)).toBe("closed"); // ← races the beat
```
Under `realTimers`, `advance(ms)` is a literal `setTimeout(resolve, ms)`
(spec L656–664). The pill's `closed → opening` transition fires at
`ERROR_GESTURE_MS.beat = 400` (`index.ts` L330–339, scheduled at
L18998). So the assertion had a **200ms margin against a 400ms boundary
on a wall clock**. On a loaded runner the 200ms sleep overshoots 400ms,
the beat has already fired, and the phase reads `opening`.
The comment directly above the test already says phase boundaries are
asserted on the fake clock "because real timers would make this suite
slow and flaky" — and then this was a phase-boundary assertion on real
timers.
The same 200-vs-400 race sat in the adjacent test (`pulsing` is true
only for the beat's 400ms), so both are fixed here.
## Fix
Remove the two racy preconditions. Both claims are already pinned
deterministically on the fake clock at spec L411–427, which asserts
`pillPhase === "closed"` **and** `pulsing === true` right after arming,
then walks every phase boundary. These real-timer tests exist only to
show the beat and the gesture run to their end on their own — which the
loops and their closing assertions still prove.
This also matches the idiom the sibling `launcher-signal.spec.ts`
already uses (L667–676): assert at t≈0, then poll for the end.
## Testing
**1. Reproduced the CI failure locally.** Injected a 250ms stall before
the assertion on the unmodified test, simulating a loaded runner (200ms
sleep + 250ms ≈ 450ms > the 400ms beat):
```
FAIL src/__tests__/launcher-error-signal.spec.ts > the whole gesture completes on its own …
AssertionError: expected 'opening' to be 'closed' // Object.is equality
Expected: "closed"
Received: "opening"
❯ src/__tests__/launcher-error-signal.spec.ts:2666:40
```
Byte-for-byte the CI assertion.
**2. The fix survives that same simulation.** With a 700ms stall (well
past the beat) injected into both tests:
```
✓ the beat ends and leaves the resting dot behind 750ms
✓ the whole gesture completes on its own and leaves the resting state behind 3435ms
Tests 2 passed | 88 skipped (90)
```
**3. Mutation-checked that the remaining assertions still have teeth.**
Three separate breaks to `src/index.ts`, each caught:
| Mutation | Result |
|---|---|
| Gesture opens but never closes (drop the `closing` phase +
`endGesture`) | `FAIL … AssertionError: expected <span …> to be null` |
| Pill never opens at all (`openPill` returns early) | `FAIL …
AssertionError: expected false to be true` (`sawOpenPill`) |
| Beat never ends (`beat: 400` → `999_999`) | `FAIL … AssertionError:
expected true to be false` (`pulsing`) |
Source restored afterwards; `git status` confirms this PR touches only
the spec file.
**4. Full `@copilotkit/web-inspector` suite:**
```
Test Files 29 passed (29)
Tests 625 passed (625)
```
**5. Pre-commit gate** (`test-and-check-packages`: test, publint, attw
across 5 projects + 22 dependencies, incl. `@copilotkit/react-core`,
`@copilotkit/angular`, `@copilotkit/runtime`) passed on the committed
tree.
## Note
`main` also has a second, unrelated flake I did not touch here —
`CopilotChatToolRerenders.e2e.test.tsx > should not re-render a
completed tool call when subsequent text is streamed` (`expected 4 to be
3`), which reddened `main` at `0943c519` ([run
32750372113](https://github.com/CopilotKit/CopilotKit/actions/runs/32750372113)),
a runtime `.d.ts` change that touches no react-core chat code. Different
mechanism, worth its own issue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
DNS is case-insensitive, so a capitalized host in prose would have slipped the
literal match. Env var names stay case-sensitive — `ignoreCase` is opt-in per
rule. The per-host reason moves onto the constant so adding a third host cannot
silently inherit the wrong message.
Also restores the TSDoc's original framing of what an override is for
("non-production or future self-hosted"), matching the runtime skill's wording
rather than diverging from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The packaged runtime skill up to v1.62.2 prescribed `api.copilotkit.ai` /
`realtime.copilotkit.ai`. The first host is a CNAME onto the legacy Copilot
Cloud ALB, where no listener rule matches it, so every request gets the ALB
default action: a 404 with an empty body. The second has no DNS record at all.
A reader who followed that page converted a working OSS install into a 502.
The hosts themselves were corrected in v1.64.0, but two shipped surfaces still
carried stale claims about the same step, and nothing stopped the hosts from
coming back a third time:
- The debug skill said Intelligence "requires ... `apiUrl`, `wsUrl`, `apiKey`,
`tenantId`". Three errors in one line: `apiUrl`/`wsUrl` have been optional
with managed defaults since v1.64.0, and `tenantId` has never existed on
`CopilotKitIntelligenceConfig` — the API key carries the project (its token
format is `cpk-{projectId}_...`) and the platform resolves the organization
server-side, so there is no org or tenant field for a caller to pass.
- `CopilotKitIntelligence`'s own TSDoc showed only `*.internal` placeholders,
so the class's hover docs never named the pair that actually serves prod.
`validate-intelligence-env-names` — already the unfiltered guard for this same
config surface (OSS-881) — now also fails on either dead host. The
channels-intelligence realtime test is allowlisted: it needs a hostname that
genuinely does not resolve, since `getaddrinfo ENOTFOUND` is the condition
under test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## What does this PR do?
Design review on the launcher and its notification dot asked for three
things: a milder face than solid black, fewer borders and background
layers, and a subtle shadow in place of the dot's heavy border. This is
all three, plus the removal of six utilities that never had any effect.
Everything here was compared side by side at production size, on a light
*and* a dark host page, before it was chosen. Two of my own first
proposals were dropped after measuring them, both described below.
**Two concerns, three commits.** `4fa38d91f` and `cd711b369` are the
launcher itself — the package change, 2 files. `295f75495` gives the
react-router lab a dark mode, because a dark host page is what this
change has to be judged against and the lab could not produce one. If
you would rather review those separately, say so and I will split them.
## The face
`#181C1F` at 95%, which review asked for.
Worth recording so it does not come up again: the near-black the review
saw was `#010507`, 20.5:1 against white. What shipped yesterday was
already `#1C1F24` at 16.5:1, so this value is a hair *darker* than the
one it replaces (17.2:1) and the difference between them is a ΔE of 2.3,
at the floor of what an eye can separate. It settles the question rather
than changing the look.
## Fewer layers
Six Tailwind utilities on the launcher set properties the unlayered
`css` block sets again — `bg-slate-950/95`, `border-white/20`, `ring-1`,
`ring-white/10` and the two hover variants. Unlayered declarations beat
layered ones regardless of specificity or source order, so none of them
has ever had any effect. Each was the package's only use, so the
checked-in stylesheet drops 980 bytes.
Of the *visible* layers, two went:
**The outer hairline.** The launcher carried two concentric lilac rings:
the border, and a second one 1px outside it as a box-shadow. The outer
one also hardcoded the lilac rather than reading `--cpk-launcher-edge`,
so it silently could not follow the token. It is replaced by a one-pixel
light edge along the top, which is what keeps the face from reading flat
without drawing a frame.
**`backdrop-blur-md`.** It sat behind a 95%-opaque fill and bought close
to nothing, while mounting a permanent blur compositing layer over a
customer's page.
**The border stays, and this is the finding that changed my mind.** I
first proposed removing it too. Against a dark host page the face
measures 1.10:1 (GitHub dark), 1.04:1 (Tailwind slate-900) and 1.22:1
(black) — indistinguishable from the page. The border is the only thing
that gives the launcher an outline there. It is not decoration.
## The dot
The collar was `1.5px`, opaque, zero blur, and 21% of the dot's
footprint. Because the dot's centre sits *on* the rim, its outer half
painted a hard dark crescent onto the **host page** rather than onto the
launcher — which is what read as "heavy". A hairline plus a soft drop
separates it just as well.
The fill becomes a lens lit from the upper left. Both stops are derived
from `--cpk-launcher-signal`, so a new tone needs no new values;
verified for the rose error tone and the violet announcement tone.
**Dropped after looking at it:** a coloured glow around the dot. It was
the obvious reading of "more premium", but the launcher already pulses
in that same colour when a failure is new, and a permanent glow competes
with the thing that is supposed to draw the eye.
**Also dropped:** tinting the border in the signal colour, which was
suggested in review. On a dark page the border is the entire silhouette,
so tinting it recolours the whole launcher for a state that can persist
for hours.
## One non-obvious consequence
Removing the blur removed a side effect nobody had written down:
`backdrop-filter` promotes the element to its own compositing layer.
Without a layer, the hover `scale(1.05)` re-rasterises the mark every
frame and it visibly jitters — geometrically nothing moves, the mark's
centre holds to three decimals, but the vector is re-rendered at
fractional offsets. `will-change: transform` asks for the layer directly
and the jitter is gone. Confirmed by eye on the running demo before this
was chosen.
## Tests
`packages/web-inspector` stays at **28 files / 611 tests**, all passing.
No new tests. The colour tests here are deliberately token-shaped rather
than value-shaped — they assert the custom property and the *sharing* of
one face and one edge between the launcher and its pill, never a hex —
so face and edge values are free to move and this change is exactly the
kind they were written to allow. The one test that constrains it, `"the
pill and the launcher share one surface and one edge"`, still passes.
What is genuinely unguarded, and was before this PR too: the dot's
collar width, the double hairline, and the Tailwind class list.
Asserting rendered geometry would need a browser test runner, which this
package does not have — jsdom computes no layout.
## How to see it
`pnpm --filter react-router-example dev`, then `http://localhost:5173`.
The launcher is top right; `Break runtime` arms the error tone and
`Break run` the announcement one. Hover it to check the mark no longer
jitters.
One thing worth knowing while reviewing: the launcher anchors top-right
and is `position: fixed` on an element mounted directly under `<body>`,
so on this page it sits over the lab's toolbar. Drag it to the lower
right and it is out of the way.
## The lab's dark mode
A dark host page is where the launcher's border earns its place, and the
lab had no way to produce one, so reviewing this change was not possible
without it.
It follows `examples/v2/react/demo` rather than inventing anything: the
host owns a `theme` state, and `CopilotChat` gets `className="dark"` —
which is what makes the package swap its own variable set. The colours
are the demo's by another route; it writes the oklch literals
CopilotKit's variables use, and those are Tailwind's neutral steps
(`neutral-950` is `oklch(0.145 0 0)`, `neutral-50` is `oklch(0.985 0
0)`, `neutral-800` is `oklch(0.269 0 0)`). Measured identical on the
running lab.
`@custom-variant dark (&:is(.dark *))` is needed in the lab's stylesheet
because Tailwind v4 points `dark:` at `prefers-color-scheme` by default,
so the toggle would have lost to the OS. Same declaration the package
uses for its own sheet.
Two details that are decisions rather than oversights. The **error
banner keeps a rose tint** in dark mode instead of going neutral,
because an error banner that looks like every other surface is not an
error banner. And the **toolbar buttons keep a visible on/off contrast**
— active inverts to a light face, inactive sits on `neutral-800` —
because the lab's whole purpose is knowing which failure is currently
armed.
My first attempt stripped every background instead of theming, and that
is worth recording because it looked plausible: the chat bubble, the
send button, the button states and the banner all collapsed into one
flat grey. The chat paints its own surfaces and has to be told what
theme it is in, not undressed.
## A separate bug found on the way
`CopilotKitProvider` documents `inspectorDefaultAnchor` — *"Default
anchor corner for the inspector button and window"* — and it has no
effect. `defaultAnchor` is typed on the React wrapper and forwarded to
the element, but the string `defaultAnchor` does not occur anywhere in
`packages/web-inspector`, so it lands as `defaultanchor="[object
Object]"` and is ignored. The corner stays hardcoded `{ horizontal:
"right", vertical: "top" }` in two places.
Not fixed here, to keep this PR to one concern. It is worth fixing: any
host with a top navigation bar hits exactly this, finds exactly that
prop, and it does nothing.
## Related PRs and Issues
- Follows #6646