Follow-up to the OSS-473 clean-break rename (#5963). Scrubs the last
internal "bot" vestiges deliberately left out of 473's atomic
telemetry-surface commit. Naming-only, no behavior change.
- `bot` local variable → `channel` in create-channel.ts factory internals
and the ~15 test files that exercise it.
- `BotNode` type → `ChannelNode` in @copilotkit/channels-ui and every
importer (channels, slack/teams/discord/telegram/whatsapp/intelligence
adapters, and the slack/teams examples).
- `botName` adapter-SPI option → `channelName`: renamed on
AdapterStartContext, its create-channel caller, and the one adapter that
reads it (IntelligenceAdapter.start ctx) in the same change so the SPI
cannot drift. The Phoenix wire contract was already `channelName` (473).
- Stale `bot-ui`/`bot-slack` package refs and "bot core"/"the bot" prose in
channels/channels-ui comments → `channels-ui`/`channels-slack`/"channel".
- `Symbol.for("copilotkit.bot-ui.*")` → `"copilotkit.channels-ui.*"`.
Deliberately kept: the platform `isBot?` author flag (an unrelated
"is this message author a bot account" semantic), and example `bot`
instance variables / "demo bot" prose (user-facing, out of this ticket's
internal-symbol scope).
Validation: DoD grep returns zero internal bot symbols in
packages/channels + packages/channels-ui; check-types + test green across
all 8 channels packages (1093 tests) and both examples.
## Problem
On the managed Teams path the agent always reported "no earlier
messages" / "I don't see the image in this thread."
`HttpDeliverySource.getHistory` was **Slack-only**: it keyed off
`threadTs` and returned `[]` for any route without one. A Teams route is
`{ adapter: 'teams', tenantId, conversationId }` (no `threadTs`), so it
short-circuited *before making any request* — starving **both** history
mechanisms on Teams:
- `agent.messages` seeding (`conversationStore.getOrCreate`), and
- the `read_thread` tool (via `thread.getMessages()`).
## Fix
- **`getHistory` is now adapter-aware** (mirrors
`conversationKeyFromReplyTarget`'s per-adapter switch): Slack keys off
`teamId`/`channel`/`threadTs`; Teams sends `adapter=teams` + `tenantId`
+ `conversationId`, matching app-api's
`teams:{tenantId}:{conversationId}` thread_key. app-api's
`/api/channels/history` route already accepts this shape. **Slack query
and order are unchanged.**
- **Add `getMessages` to the adapter** so `thread.getMessages()` (the
`read_thread` tool) reads reconstructed history via the transport and
maps it to `ThreadMessage[]`. Without it `Thread.getMessages()` returns
`[]` and thread-reading tools (summaries, "what was in the image") see
nothing even when history exists.
## Tests
- New: Teams-shaped `getHistory` query, and the
missing-`tenantId`/`conversationId` short-circuit (no request).
- All 115 `channels-intelligence` tests pass; `check-types` clean.
## Notes
Verified end-to-end against a live managed Teams bot as a dist patch
before porting to source (history seeding + `read_thread` both start
returning the thread's messages). The app-api counterpart (Teams
ingress: reactions, inline-media/Graph file ingest, slash commands) is a
separate PR in the Intelligence repo.
## Summary
Makes the normal `createCopilotRuntimeHandler` own Managed Channels
activation, readiness, reconnect, and shutdown. A developer declares a
Channel next to their agents in an existing `CopilotRuntime`, adds
`intelligence`, and mounts the normal handler — **no separate launcher,
no gateway URLs, no org/project/channel/runtime-instance IDs**.
```ts
const support = createChannel({ name: "support", agent: () => supportAgent });
const runtime = new CopilotRuntime({ agents, intelligence, identifyUser, channels: [support] });
export const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" });
await handler.channels.ready({ timeoutMs: 10_000 });
handler.channels.status();
await handler.channels.stop();
```
Implements the CopilotKit-SDK half of the Managed Channels SoT
**workstream A**. Closes OSS-473.
## What changed
- **`channels:` runtime option** on the Intelligence runtime (public
alias replacing the internal `bots` field); the SSE runtime rejects it.
- **`ChannelManager`** (`packages/runtime/.../core/channel-manager.ts`)
owns the managed lifecycle: lazy, idempotent `activate()`; `ready({
timeoutMs })`; `status()` (`connecting | online | setup_required |
reconnecting | stopped | unmanaged | error`); idempotent, resilient
`stop()`. Activation config (`wsUrl`, `apiKey`, `projectId`,
`channelName`, `provider`, `runtimeInstanceId`) is **derived from the
`intelligence` config + the declared channel** — `projectId` is parsed
from the `cpk-{projectId}_` API-key prefix, and the managed `provider`
is declared **per-Channel** via `createChannel({ provider })` (type
`ManagedChannelProvider`, defaults to `slack`; `teams` is
gated/coordinated — the gateway accepts only `slack` at join today, so
`teams` is SDK-ready but not GA until Intelligence OSS-450/#511 lands) —
so no infrastructure IDs are supplied by the developer.
- **`createCopilotRuntimeHandler` returns a callable object** — for an
Intelligence runtime with declared channels the `channels` control is
**non-optional** (`((req) => Promise<Response>) & { channels:
ChannelsControl }`; optional for SSE / channel-less runtimes).
Activation is **lazy and serverless-safe**: handler creation opens
**no** connection; the persistent gateway socket opens on the first
`await handler.channels.ready()`, so a Fetch host that cannot own a
listener (Cloudflare Workers, Next.js App Router) never opens one. Call
`ready()` once at startup on a long-running host. Idempotent per-runtime
via a `WeakMap`; additive and non-breaking; propagated through the
node/express/hono endpoint wrappers.
- **Clean-break rename** `createBot → createChannel` / `Bot → Channel`
(and `CreateChannelOptions`, `ChannelHandler/Component/Tool/Command`,
`defineChannelTool/Command`) across `@copilotkit/channels`,
`-intelligence`, the adapter packages, `@copilotkitnext/teams`, and the
examples — no public `Bot` aliases, per the SoT.
- **`examples/slack`** managed path rewritten to the no-launcher DX
(`createChannel` + `channels:` + `createCopilotNodeListener` +
`handler.channels.stop()` on SIGTERM/SIGINT); the six `INTELLIGENCE_*`
launcher env vars removed.
- The `channels-intelligence` realtime launcher is kept working and is
now driven internally by the handler.
## Reconnection & connection health (note for reviewers)
The actual reconnect/rejoin is **delegated to the Phoenix connection
layer**, not a runtime-managed backoff loop. Verified against the live
gateway contract (Intelligence #511): Phoenix's `Socket` auto-reconnects
and auto-rejoins, which re-runs the gateway `join/3` →
`record_heartbeat` (re-registers the listener), and `terminate/2`
releases the dead socket's lease. A manager-level re-activation loop
would be both **redundant** and **incorrect** (the `Channel` is
single-start — re-running `addAdapter`/`start` throws).
What the handler owns is **observation of that connection**. The session
exposes `onStateChange` and the manager reflects it in `status()`: a
dropped socket **or a Phoenix channel-level close/error** moves the
channel to `reconnecting`; a successful (re)join moves it back to
`online` — so `online` means the managed path can currently send. A
**bounded give-up window** (`reconnectGiveUpMs`, default 60s) surfaces a
prolonged outage as `error`, but it is **recoverable**: if Phoenix
rejoins afterwards the channel returns to `online` — the state always
tracks the live transport, never latching. Join rejections are
**classified per-Channel** from the gateway's declaration states:
genuinely unconfigured/waiting states (`adapter_setup_required`,
`*_waiting_for_runtime`, `no_channels_yet`, …) surface as
`setup_required` (and `ready()` resolves); a `runtime_conflict` or a
`*_failed`/hard state surfaces as an **error** and is never silently
downgraded to setup-required. `ready()` keeps one-shot semantics — it
settles on the initial activation outcome; later health transitions move
only `status()`.
## Testing
Unit + integration: handler-owned activation with config derived purely
from `intelligence` (asserts **no** org/channelId on the wire); **first
request does not trigger activation**; `ready`/`status`/`stop`; a
socket-drop path; duplicate-name fail-loud; setup-required surfacing;
the default engine's opts mapping + module-not-found path. `build` +
`check-types` + `test` green across `@copilotkit/channels`,
`-intelligence`, and `runtime` (1726 tests).
## Residual risk / verification
The dynamic `import()` of `@copilotkit/channels-intelligence`, the real
Phoenix socket join, and the example's node listener are proven against
fakes but not a live gateway. A **packaged-SDK → Realtime Gateway →
provider e2e smoke** is recommended before production reliance (SoT
workstream D; pairs with landing Intelligence #511).
## Follow-ups (out of this PR's activation-lifecycle subject)
Filed separately: managed transport parity/reliability — cross-turn
history (OSS-436), command/interaction/reaction ingress over the
realtime path (OSS-416/419/434), `thread.delete()` egress, empty-turn
redelivery, egress-failure fail-loud, durable StateStore on the managed
path, and the reconnect-observability / `stop()`-timeout hardenings.
Docs update tracked in #5925. Id-less gateway topic in OSS-480.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Adapter transport model (clarified per review)
This PR is **exclusive per Channel**, not per platform, and there is no
simultaneous coexistence. **Any** developer-supplied **direct** adapter
on a Channel declared in `channels:` makes the **whole** Channel
`unmanaged` and skips it for managed activation — regardless of platform
— rather than dual-activating, because attaching the managed gateway
transport beside a direct adapter would open two live connections to the
provider and deliver every event twice (`assertExclusive` enforces the
adapter exclusivity). The skipped Channel is not silently "healthy": it
is reported with an explicit **`unmanaged`** status (a runtime whose
only Channel is direct reads `overall: "unmanaged"`, never `online`),
and the handler neither starts nor stops it — the developer owns its
`channel.start()`. Having the handler own the lifecycle of a direct
Channel too (either-or per Channel) is **OSS-486**; true managed+direct
coexistence on one Channel is **OSS-484**.
Classify per-channel state on channel_declaration_unavailable rejects so a
runtime_conflict is a hard error rather than being downgraded to setup_required;
make gave_up recoverable (a later rejoin restores online); and route Phoenix
channel-level close/error through the same health transition as socket drops.
The PR's documented snippet — `await handler.channels.ready(...)` with no
`!`/`?.` — did not type-check under strict TS because
`createCopilotRuntimeHandler` always returned `channels?: ChannelsControl`.
Encode channel-presence at the type level:
- runtime.ts: `CopilotRuntime` is now a `const` typed as `CopilotRuntimeConstructor`
(backed by an internal `CopilotRuntimeShim` class; behavior unchanged). A
class constructor cannot vary its return type across overloads, so the two
construct-signature overloads live on the constructor interface: `intelligence`
+ a non-empty `channels` tuple returns a `RuntimeWithDeclaredChannels`-branded
runtime; every other config (SSE, intelligence-without-channels, empty
`channels: []`, or a non-literal `Channel[]` variable) stays unbranded. The
brand is a phantom (compile-time-only) property. `export interface CopilotRuntime`
preserves the name as a type for existing `runtime: CopilotRuntime` / `as
CopilotRuntime` sites.
- fetch-handler.ts: overload `createCopilotRuntimeHandler` — a branded runtime
(unless `activateChannels: false`, constrained to `true | undefined`) returns
the new `CopilotRuntimeFetchHandlerWithChannels` (non-optional `channels`);
everything else keeps the optional shape. Opting out of activation honestly
falls through to the optional overload.
- Added a compile-time type test (checked by `tsc --noEmit`, the `check-types`
gate). It probes the optionality modifier structurally (`{} extends Pick<T,K>`)
rather than for `undefined`, since this package compiles `strict: false`.
Confirmed it fails pre-change on the required-channels assertion and passes
after. Dropped the now-unnecessary `!` in handler-channels.test.ts.
Call sites: the second overload is byte-identical to the former single signature,
so every `createCopilotRuntimeHandler` caller (node/express/hono endpoints,
integration servers, examples) and every `new CopilotRuntime` site resolves
unchanged; only inline non-empty-`channels` construction gains the (strict
supertype-assignable) branded type. Verified via a clean full-package check-types.
P1#2 — reachable setup_required on the PRODUCTION engine path.
connectRealtimeGateway no longer flattens every join rejection into a
generic Error. A join `.receive("error", reason)` whose reason is a known
setup-required code (`channel_declaration_unavailable`, and defensively
`adapter_setup_required` / `not_configured`) now rejects with a
distinguishable `RealtimeGatewaySetupRequiredError` (`code === "SETUP_REQUIRED"`,
raw reason preserved). ChannelManager already detects that code, so an
unconfigured managed provider now degrades to `setup_required` (ready()
resolves) instead of `error`. All other reasons keep the generic error and
the socket-leak teardown is unchanged.
P1#3 — status() reflects real connection health instead of `online` forever.
ConnectedRealtimeGatewaySession exposes `onStateChange(cb)` over
`RealtimeGatewayConnectionState` (`online` | `reconnecting` | `gave_up`),
driven by the real Phoenix seams: an unexpected socket drop → `reconnecting`;
a successful (re)join (the join-push recHooks survive Phoenix `resend`, so
`"ok"` re-fires on every auto-rejoin) → `online`; and a BOUNDED give-up —
Phoenix retries forever, so a `reconnectGiveUpMs` window (default 60000, runs
from the first drop of an episode, cleared on rejoin) elapsing while still
reconnecting → `gave_up` (terminal). Our own disconnect() stays silent.
ChannelManager wires this in place of the log-only onClose breadcrumb:
`reconnecting`→status reconnecting, `online`→online, `gave_up`→error; a
stopped manager/entry ignores late events. computeOverall now ranks
`error > reconnecting > setup_required > connecting > online`. ready() keeps
its one-shot semantics (settles on the initial outcome); later health
transitions move only status(). Docs updated to state `online` means
currently-sendable.
Wording: the direct-adapter skip comment/log now states delivery is
exclusive-per-platform (managed OR direct per platform, not both — attaching
both would double-deliver) with true coexistence tracked in OSS-484. Skip
behavior unchanged.
Call-sites for the changed signatures:
- connectRealtimeGateway error shape: only caller is
startChannelsOverRealtimeGateway (realtime-gateway-launcher.ts:216); it
awaits and lets the rejection propagate, so the setup-required error flows
through unchanged (no branch to update).
- new ConnectedRealtimeGatewaySession.onStateChange: passed through in
startChannelsWithGatewaySession and startChannelsOverRealtimeGateway
(realtime-gateway-launcher.ts); added to ChannelsHandle (runtime.ts) and the
manager's local ChannelsHandle view (channel-manager.ts); exported from
index.ts. RealtimeGatewaySession (base, no observer) consumers
(realtime-gateway-transport.ts) unaffected.
- manager onClose→state transitions: registerOnClose renamed to
registerConnectionObserver; sole caller is the online settle handler in
activate().
Honors the SoT "never infer managed intent from a direct adapter" rule.
Channel.adapters is a new additive read-only member; verified no consumer
constructs Channel literals (only createChannel does).
RC15: getOrCreateChannelManager bridged the manager log as
`logger.warn({ meta }, msg)`, but pino only serializes an Error's
(non-enumerable) message/stack under the `err` key — under `meta` a
failed activation rendered as `{}`, losing the cause. Route an Error to
`err` and keep `meta` for everything else.
LEVER: removed the channel-name FORMAT/length validation block plus the
replicated CHANNEL_NAME_PATTERN / MIN / MAX constants from
channel-activation-config.ts. This was a third copy of managed-specific
rules whose source of truth is channels-intelligence's
assertValidChannelRealtimeScope + assertValidChannelNames, and it kept
drifting (omitted the reserved-name rule). Now that activation failures
are logged, recorded as `error` status, and surfaced via ready(), the
up-front check is not worth cross-package rule parity. Missing/empty
name still throws (the config's own precondition). Deleted the obsolete
"Slack"/"support_bot"/"cs"/65-char rejection tests.
projectId>0: parseProjectIdFromApiKey now throws ChannelConfigError when
the parsed id is <= 0 (`cpk-0_...` matched but failed deep in the
launcher). Parser validating its own output, not a channel-name replica,
so it stays here; reuses the existing key redaction.
adapter default hardening: `adapter ?? "slack"` -> truthiness/trim check
so ""/whitespace falls back to "slack".
activate() stopped-guard: short-circuits on `this.activated || this.stopped`
so a post-stop() activate() opens no transports on a dead manager.
coverage: exported defaultActivateChannel (the real engine) with an
injectable importer seam (optional param, default = the same non-literal
dynamic import) and covered its 3 branches — config->opts mapping (scope
carries only projectId+channelName), module-not-found friendly error, and
generic-error passthrough. Added cheap manager coverage: lazy-activate
duplicate-name reject via ready(), empty channels[] -> online + ready
resolves, non-default adapter reaches the engine config.
Call sites (no external breakage):
- CHANNEL_NAME_PATTERN/MIN/MAX: were module-private; zero references.
- parseProjectIdFromApiKey: only internal caller is
deriveChannelActivationConfig (passes the real key) + tests; <=0 throw
affects only malformed cpk-0 keys.
- defaultActivateChannel: only internal use is the ChannelManager
constructor default (called 2-arg) + tests; new 3rd param is optional
and the fn still satisfies ActivateChannelEngine.
- ChannelsIntelligenceModule: new additive export, referenced internally
+ tests only.
- activate() guard / logger bridge: internal only.
CR batch for packages/runtime managed-channels activation:
- RC11: getOrCreateChannelManager now passes a `log` adapter bridging the
ChannelManager diagnostic sink to the shared logger
(`log: (msg, meta) => logger.warn({ meta }, msg)`). Previously every
breadcrumb (setup_required, failed-to-activate, dropped-session,
teardown-stop failure) was a no-op, so a channel that failed to activate
was permanently dead with zero output.
- f2: stopEntry logs a swallowed handle.stop() error via the sink instead of
discarding it; teardown stays resilient (never rethrows).
- sync-throw guard: stopEntry wraps handle.stop() in
`Promise.resolve().then(...)` so a foreign/injected handle that throws
SYNCHRONOUSLY is caught by the same `.catch` — otherwise the throw escaped,
skipped resolveSettled(), and hung `settled` forever.
- f3: ready() short-circuits and RESOLVES when the manager is stopped. A
channel that settled to `error` before stop() had already rejected its
`settled` promise, so a later ready() threw an AggregateError even though
status().overall was "stopped" — now consistent with the after-stop case.
- RC12: parseProjectIdFromApiKey no longer slices a fixed 8 chars off an
arbitrary key (which echoed secret bytes for a `cpk-_...`-shaped key). The
failure message now echoes NONE of the key value, only the expected
`cpk-{projectId}_` format hint.
- RC13: deriveChannelActivationConfig enforces the lowercase-kebab-case
channel-name rule (/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, length 3-64) up front
and throws a clear ChannelConfigError, instead of passing a bad name to the
launcher where assertValidChannelRealtimeScope throws deep and the channel
is silently degraded to `error`. Regex/bounds are a literal copy of
channels-intelligence's assertValidChannelRealtimeScope (the source of
truth; not statically imported — it's an optional pure-ESM peer).
- ready() docblock reworded: async ready() REJECTS (not throws) the
ChannelConfigError.
Tests (red-green verified): RC11 (handler logger spy + manager log sink),
f3 (ready resolves post-stop after pre-stop error), sync-throw guard, RC12
(no secret-tail leak), RC13 (Slack/support_bot/cs/65-char reject; support
passes).
Call-site enumeration for changed signatures/behaviors:
- getOrCreateChannelManager (new internal `log` arg): single caller
fetch-handler.ts:402; public signature unchanged, no caller impact.
- deriveChannelActivationConfig (RC13 now throws on bad name): single caller
channel-manager.ts:323 inside activate()'s per-channel loop, already
wrapped in try/catch that converts a throw to a rejected activation ->
recorded as `error` status and surfaced via ready()'s AggregateError.
- parseProjectIdFromApiKey (RC12 message-only change): single caller
channel-activation-config.ts:131; error type/behavior identical.
- ready() (f3 early-return): only affects a stopped manager (now resolves
instead of throwing) — strictly more lenient; sole public reference is a
doc example in endpoints/node.ts:42.
Skipped: a test exercising defaultActivateChannel's module-not-found friendly
error — the fn is unexported and the dynamic import specifier is not
injectable, and channels-intelligence IS installed in the workspace so a real
MODULE_NOT_FOUND can't be forced without contorting the code. isModuleNotFound
remains unit-tested.
Centralize the stop-vs-settle race class in ChannelManager behind one guarded,
idempotent teardown path instead of per-branch patches:
- ChannelEntry gains a private `handleStopped` flag; new private `stopEntry()`
sets status="stopped" and stops the handle AT MOST once. Both settle handlers
and stop() route through it.
- RC5: a rejection arriving AFTER stop() now keeps the entry "stopped" and
resolves settled (no error/setup_required, no rejectSettled), so a late
connect failure can't resurrect a stopped channel or reject a later ready().
- RC7: stop() runs `Promise.allSettled` over per-entry stopEntry() calls; the
handleStopped guard means a handle assigned in the same tick as stop() is
stopped exactly once even when both stop() and the success handler reach it.
- RC9 (fetch-handler): getOrCreateChannelManager now calls activate() BEFORE
inserting into the WeakMap, so a synchronous throw (duplicate/missing names)
caches nothing and every retry re-throws instead of returning an inert
manager that falsely reports "online".
- RC8: reconcile class + ready() docstrings — activation throws synchronously
(ChannelConfigError) only on up-front misconfiguration; all other failures
are recorded as channel status.
- assertUniqueChannelNames checks missing/empty name FIRST so two nameless
channels get the accurate "missing name" error, not a spurious "undefined"
duplicate.
- Remove the dead ChannelEntry.promise field (unread residue of the removed
reconnect path).
- RC4 (packaging): move @copilotkit/channels-intelligence from
optionalDependencies (auto-installed, force-pulls the pure-ESM package into
every OSS consumer) to an optional peerDependency, mirroring the other
optional integrations.
- Test nits: clear the dangling stop()-hang setTimeout; drop the redundant
not.toBe("reconnecting") assertion.
Call sites of changed symbols:
- stopEntry (new private): channel-manager.ts only — success handler, reject
handler, and stop(); no external callers.
- ChannelEntry.promise (removed): grep confirms no reads anywhere in the repo
(the only .promise reads are unrelated test signals).
- getOrCreateChannelManager (reordered, no signature change): single caller at
fetch-handler.ts createCopilotRuntimeHandler.
TDD: RC5 and RC9 red-green verified against prior code (RC5 reported "error"
not "stopped"; RC9 retry returned an inert healthy manager). RC7 pins the
single-stop guarantee for the new idempotent design.
RC1 — remove manager-level re-activation reconnect (delegate to Phoenix). The
ChannelManager's supervised reconnect re-invoked the activation engine on the
SAME already-started Channel, which throws in channel.addAdapter (started=true)
— so it could never succeed on the real launcher. It was also redundant:
Phoenix's Socket auto-reconnects and auto-rejoins, re-sending the join
declaration; the gateway's join/3 re-runs record_heartbeat (re-registers the
listener) and terminate/2 releases the dead socket's leases (verified against
Intelligence #511 sdk_channel.ex). Removed: runReconnect, reconnectLoops,
onChannelClosed, the RECONNECT_BASE_DELAY_MS / RECONNECT_MAX_DELAY_MS /
RECONNECT_MAX_ATTEMPTS constants, the injectable sleep arg + defaultSleep, and
stoppedSignal/resolveStopped. onClose is now a log-only breadcrumb (no state
mutation, no re-activation). Once a channel activates it stays online; a
transient drop is invisible (Phoenix self-heals). ChannelStatus keeps
"reconnecting" in the union marked reserved to avoid churning the public type;
computeOverall no longer assigns it.
RC2 — stop() no longer aborts teardown on a throwing handle.stop(). The real
launcher's stop() rethrows after session.disconnect(), so Promise.all rejected
and skipped the status loop; with stopped already set, a retry no-oped, leaving
the manager permanently un-torn-down. Switched to Promise.allSettled so every
handle attempts teardown and every entry is marked "stopped". Red-green test
added.
RC3 — parseProjectIdFromApiKey no longer echoes the full cpk-… secret in
ChannelConfigError (it is logged and surfaced via ready()'s AggregateError).
Message now includes only a short non-sensitive prefix; test asserts the format
hint is present but the full key is not.
Reconnect unit test rewritten to the new contract (a drop makes no further
engine call, does not throw, manager stays usable/coherent); obsolete
backoff-growth / give-up-to-error / cancel-pending-backoff cases removed.
Integration test step 5 updated: a drop stays online with no extra engine call.
Call sites cleared: grep over packages/runtime/src for runReconnect,
reconnectLoops, RECONNECT_BASE_DELAY_MS, RECONNECT_MAX_DELAY_MS,
RECONNECT_MAX_ATTEMPTS, onChannelClosed, stoppedSignal, resolveStopped,
defaultSleep, and the injectable ChannelManager sleep arg returns no matches;
fetch-handler exposes no sleep/reconnect seam. Nothing external referenced the
removed symbols.
A1: ChannelManager.activate() now asserts unique channel names before any
engine call (entries keyed by name silently leaked the first session on a
duplicate). Throws ChannelConfigError naming the dup. Reworded the stale
runtime.ts comment that claimed startChannels validates uniqueness — the
managed path activates one Channel per launcher call, so uniqueness is
enforced by ChannelManager.activate().
A4: stop() no longer awaits pending activations (a hung connect that
ready({timeoutMs}) tolerates would hang teardown/SIGTERM forever). It stops
only handles that already exist; a post-settle guard on the initial-activation
path tears down any handle arriving after stop(), mirroring the reconnect
loop's guard. Idempotent.
A3: reconnect success clears the reconnectLoops marker BEFORE re-arming
onClose, so a synchronous onClose re-fire on the fresh handle starts a new loop
instead of leaving the Channel stuck reconnecting with no loop.
B1/B3: doc fixes — onClose seam is present-tense (launcher delegates to
session.onClose); parseProjectIdFromApiKey @throws no longer describes an
unreachable empty-segment case.
Call sites reviewed (behavior holds at each):
- activate() throws on dup: fetch-handler.ts getOrCreateChannelManager (l.187),
reached from createCopilotRuntimeHandler at handler-creation time → now fails
loud at startup instead of leaking; channel-manager ready() (l.424) surfaces
the throw as a rejected promise.
- stop() prompt-resolve: examples/slack/app/managed.ts:174 SIGTERM shutdown
await listener.channels?.stop() — the exact hang this fixes. Endpoint
adapters (node/express/hono) only attach .channels; no direct stop callers.
Tests: channel-manager.test.ts + channel-manager-reconnect.test.ts 17 passed
(2 new + 1 new, red→green); channel-activation-config + fetch-handler green;
@copilotkit/runtime:check-types clean.
Addresses review on #5969:
- Add getMessages tests: string content, content-part array (text parts joined,
non-text contributes ""), and role→isBot/user derivation; plus the no-getHistory
transport case returning [].
- Log on the unexpected getMessages catch (matches conversationStore's seeding
path) instead of degrading to [] silently — HttpDeliverySource already swallows
its own fetch failures, so this outer catch only fires on a real throw.
- Fold the duplicated role boolean (isBot computed once, drives both fields).
## Summary
Companion structural PR to #5971. It fixes the **root cause** behind the
a2ui divergence #5971 patched: the showcase's single-source symlinks
eroded to real, drifting copies.
`showcase/integrations/*/tools`, `*/shared-tools`, `*/_shared` are meant
to be **symlinks into `showcase/shared/...`** — `stage_shared()`
dereferences them for the Docker build, `restore_symlinks()` restores
them. An **accidental `stage_shared()` leak** (commit `534cd1efa7`, PR
#4449 "D5 all-green") committed the dereferenced real files instead of
restoring the symlinks. Once they were real files, they drifted — which
is exactly how the a2ui `render_a2ui` vs `_design_a2ui_surface` split
(fixed in #5971) arose.
## Changes
- **Restore 12 Python `tools/` dirs to symlinks** →
`../../shared/python/tools` (ag2, agno, claude-sdk-python, crewai-crews,
google-adk, langgraph-fastapi, langgraph-python, langroid, llamaindex,
ms-agent-python, pydantic-ai, strands). Content is byte-adopted from
shared — verified no load-bearing per-integration code is lost (only
`render_a2ui` naming + shared `roll_dice`/sanitize additions).
Integrations' intentional internal-planner names (llamaindex,
ms-agent-python) live in `src/`, not `tools/`, and are untouched.
- **`showcase/AGENTS.md` (+ `CLAUDE.md`, root pointers,
INTEGRATION-CHECKLIST section)** — canonical statement of the 4 iron
rules (identical tests, near-identical frontends, minimal backends,
per-integration fixtures) + the single-source symlink mechanism ("edit
the shared source only; a real file there is a bug"). These were
previously written down nowhere.
- **`validate-shared-symlinks` CI guard** — fails on any NEW erosion
(real dir where a symlink belongs), with a shrink-only baseline that
tightens to fully-enforcing as symlinks are restored. Mirrors the
existing `validate-*` ratchet pattern.
## Scope / independence
- **No overlap with #5971** — this PR touches nothing under
`showcase/shared/typescript/` and does not modify the 3 TS integration
`shared-tools/` dirs (verified: empty file-set intersection). Mergeable
independently.
- Build-safe: `stage_shared()` correctly dereferences the restored
symlinks (targets resolve within the build context);
`restore_symlinks()` recreates them post-build.
## Verified
- `validate-shared-symlinks` test suite: 7/7 pass; validator EXIT 0 (no
new erosion).
- Reviewed by a full panel (correctness, content-integrity, build/CI,
docs, scope, silent-failure, simplicity) — zero mandatory findings.
## Follow-ups (deliberately out of scope)
1. **3 TS `shared-tools` dirs** (mastra, claude-sdk-typescript,
langgraph-typescript) remain real (baselined) — symlink them in a
follow-up **after #5971 merges**, to avoid overlapping its TS edits.
2. **Guard hardening**: validate the symlink *target* (not just that
it's a symlink), fail-loud on a malformed baseline, and code-enforce the
shrink-only ratchet. (This PR's guard catches the real-file erosion —
the actual failure mode; these are robustness extras.)
3. Pre-existing `shared/python` a2ui test failures (#5971-adjacent) and
a couple of stale doc line-refs, noted during review.
Companion: #5971.
## Summary
The TypeScript A2UI operation builders
(`buildA2uiOperationsFromToolCall`) emitted the **legacy flat**
operation shape (`{ type: "create_surface", surfaceId }`). A2UI
consumers process operations by their **nested** `createSurface` /
`updateComponents` / `updateDataModel` keys — a flat op is never
processed as a valid nested operation, so the surface's schema and
components are never applied and the UI renders nothing (the
`generate_a2ui` / `render_a2ui` path).
Python was fixed to the v0.9 nested shape long ago (#4792, #5832); the
TypeScript builders were **born flat and never fixed** — a Python/TS
parity gap with no guard. This aligns the TS side and adds a guard so
the two can't silently drift again.
## Changes
- **4 TS builders → v0.9 nested** (byte-identical):
`shared/typescript/tools` + integrations `mastra`,
`claude-sdk-typescript`, `langgraph-typescript`.
- **Empty-data parity fix**: TS `if (data)` treated `{}` as truthy and
emitted a spurious `updateDataModel` op; Python `if data:` does not. Now
guarded to match Python (empty object → no `updateDataModel`). Our
mastra fixture records `"data": {}`, so this is exercised directly.
- **v0.9 parity guard test** in all 4 test files (asserts nested keys,
no flat `type`).
- **12 `gen-ui-a2ui-fixed` aimock fixtures** for the fixed-schema a2ui
demo.
## Red–green evidence
- Empty-data: pre-fix builder emits 3 ops on `data:{}` →
`toHaveLength(2)` **FAILS (red)**; fixed builder emits 2 → **passes
(green)**.
- Parity guard: flat shape → `.type` present / nested keys absent →
**red**; nested → **green**.
## Validation (please read — what CI does and doesn't cover)
CI **does** run `check-types`, `format`/`oxlint`, `Validate Showcase`,
and `build-check` on the changed integrations (mastra,
langgraph-typescript, claude-sdk-typescript) — these catch TS/build
breaks. But the unit-test workflow has `paths-ignore: showcase/**`, so
the **showcase vitest suites where the parity guard and aimock-fixtures
tests live are NOT run in CI**. Those were validated **locally**:
- `aimock-fixtures`: **837 passed** (all 12 new fixtures valid).
- Parity guard + empty-data red–green: **verified**
(`showcase/shared/typescript` vitest).
- `tsc --noEmit --strict`: **clean** on all 4 builders.
- **mastra Playwright screenshot**: the `render_a2ui` flow renders the
flight card (SFO→JFK, Flight Details, $289) with the nested ops.
## Real-surface confirmation (bin/showcase test --direct)
Proven on the live probe, not just unit tests:
- **RED** (old flat builder): `d6:mastra a2ui-fixed-schema` → `1 failed
— [data-testid="a2ui-fixed-card"] failed to mount within 60000ms`.
- **GREEN** (this branch, rebuilt image): `d6:mastra a2ui-fixed-schema`
→ `✓ green, 1 passed` — the card mounts and renders (SFO→JFK, UNITED,
$289, "Book flight").
- **≥3 cells `--direct` GREEN**: `mastra` ✓, `langgraph-typescript` ✓,
`pydantic-ai` ✓.
- **Frontend parity**: mastra ≡ langgraph-python render the same flight
card (byte-identical frontend; only expected agent-specific
tool-row/ordering differences).
## Iron-rule adherence
| Rule | Evidence |
|------|----------|
| Identical tests | One shared harness probe (`d5-gen-ui-a2ui-fixed`)
measures the feature across all integrations; no per-integration test
copies added. |
| Near-identical frontends | mastra ≡ langgraph-python frontend
(byte-identical); visual parity confirmed on the rendered card. |
| Minimal backends | Change is the minimal flat→nested + empty-data
guard; 4 TS builder copies byte-identical. |
| Per-integration fixtures | 12 `gen-ui-a2ui-fixed.json`, one per slug,
context-keyed. |
Note: the *single-source symlink* restoration (the
`shared-tools/`/`tools/` symlinks that eroded to real files repo-wide)
is handled in a **companion structural PR**, plus a `showcase/AGENTS.md`
documenting the iron rules and a CI check that fails on future erosion.
## Known follow-ups (out of scope here)
1. **`render_a2ui` naming drift (pre-existing):** the *shared* builder
exports `_design_a2ui_surface` while the 3 integrations + Python use
`render_a2ui`; the shared test asserts `render_a2ui` and is red on
`main` today. Not run by CI. A one-line rename aligns shared to the
source of truth and greens the file — happy to fold it in if wanted.
2. **Integration test copies aren't executed** by their vitest `include`
globs (only `shared/typescript` runs its copy) — add a cross-copy
byte-identity CI check.
3. **Full `_sanitize_a2ui_components` parity** — TS forwards components
raw; Python sanitizes (drops entries missing id/component, unstringifies
Gemini JSON-string arrays).
4. **Showcase tests are CI-excluded** (`paths-ignore: showcase/**`) — no
automated gate for showcase unit tests.
Restore the 12 Python integration tools/ dirs to symlinks into
shared/python/tools. They had eroded to real, drifting copies via an
accidental stage_shared() leak (commit 534cd1efa7) — the structural root
cause of showcase divergence bugs. Symlinking re-establishes the single
source of truth; content is identical to shared (only render_a2ui naming
and the shared roll_dice/sanitize additions are adopted).
Add showcase/AGENTS.md documenting the 4 iron rules and the single-source
symlink mechanism, plus a validate-shared-symlinks CI guard (shrink-only
baseline) that fails on any NEW erosion.
The shared TS builder exported `_design_a2ui_surface` / `DESIGN_A2UI_SURFACE_TOOL_SCHEMA`
while the Python builder, all integration copies, and agent_server.ts use `render_a2ui`
/ `RENDER_A2UI_TOOL_SCHEMA`. Align the shared source to that source-of-truth name; this
also unbreaks the re-export in shared/typescript/tools/index.ts.