Commit Graph

2081 Commits

Author SHA1 Message Date
Benjamin Taylor ed37e418ca fix(runtime): bound ChannelManager.stop(), surface ready() AggregateError on hang, forward log to transport (OSS-482)
Three ChannelManager observability/robustness hardenings from the OSS-473 CR.

stop() per-handle timeout: stopEntry awaited handle.stop() unbounded, so a
wedged stop() (e.g. a socket.disconnect that never returns) hung teardown — and
thus SIGTERM shutdown — forever. Each handle.stop() is now bounded by
stopHandleTimeoutMs (default 5000); on timeout it is logged and abandoned so
every other entry still reaches `stopped`.

ready() surfaces the real reason on hang: ready({ timeoutMs }) wrapped the whole
`allSettled` in one timeout, so when one channel settled to `error` while a
sibling hung, it rejected with only a generic timeout and DISCARDED the erroring
channel's reason. The deadline is now applied PER CHANNEL, so the AggregateError
carries both each failed channel's real reason AND a named timeout for each
still-hanging channel.

Log forwarding: the manager's `log` reached activation-level events only; the
default engine (defaultActivateChannel → startChannelsOverRealtimeGateway) never
passed it down, so transport-level drop diagnostics (e.g. a version-skew
missing-leaseToken outage) were silent in the managed path. `log` is now
forwarded down to the launcher/transport.

Note: the reconnect "gave-up → error" escalation from the same CR is already
implemented end-to-end in OSS-473 (realtime-gateway.ts reconnectGiveUpMs +
ChannelManager.registerConnectionObserver) and is not re-done here.

Tests: stop() resolves + logs a timeout when handle.stop() never settles;
ready() aggregate contains both a real activation error and the hung sibling's
named timeout; defaultActivateChannel forwards its log sink to the launcher opts.
2026-07-15 07:44:35 -05:00
Benjamin Taylor 2b4b19cf3b fix(channels): fail loud on egress failure + bound realtime delivery dispatch (OSS-482)
Two transport reliability hardenings on the managed realtime path, from the
OSS-473 CR.

emit() fail-loud: IntelligenceAdapter.emit() returned a synthetic MessageRef on
`{ ok: false }`, acking a failed post/update/delete as success (silent egress
drop on the HTTP-fallback path). It now throws with the failure code so the
failure propagates up the render/run path and the delivery is nacked/retried.

Realtime delivery dispatch: `session.on(delivery.available)` fired
`void this.handleDeliveryAvailable(...)` with no error boundary and no per-turn
deadline — an onDelivery rejection became an unhandled rejection (silent drop)
and a hung handler pinned the delivery forever. Replaced the `void` with a
`.catch` and wrapped the turn in a bounded per-turn timeout that nacks + logs on
failure/timeout (parity with the HTTP runLoop's turnTimeoutMs).

Tests: egress `{ ok: false }` makes thread.post throw (no silent ack); an
onDelivery throw and an onDelivery that exceeds deliveryTimeoutMs both nack
(channel.delivery.fail.v1) and log.
2026-07-15 07:41:17 -05:00
Benjamin Taylor 483880583f refactor(channels): scrub residual internal "bot" naming → "channel" (closes OSS-485)
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.
2026-07-15 07:39:50 -05:00
Ben Taylor 0b00f6ea37 fix(channels-intelligence): Teams thread history (getHistory + getMessages) (#5969)
## 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.
2026-07-15 07:39:17 -05:00
Benjamin Taylor 9886d0b3f9 refactor(channels): rename ChannelProvider to ManagedChannelProvider, gate teams in docs (OSS-473) 2026-07-15 07:15:07 -05:00
Benjamin Taylor 2a72ca15e3 docs(runtime): describe channel skip as exclusive per Channel, not per platform (OSS-473) 2026-07-15 07:15:06 -05:00
Benjamin Taylor 0e5dc5f97c fix(channels-intelligence): make gave_up sticky until a successful rejoin (OSS-473) 2026-07-15 07:15:06 -05:00
Benjamin Taylor 4cc85a945b docs(channels): use channel vocabulary in the quickstart, not bot (OSS-473) 2026-07-15 07:15:06 -05:00
Benjamin Taylor fbf35ac593 fix(runtime): defer channel activation to ready() so the Fetch handler is serverless-safe (OSS-473) 2026-07-15 07:15:06 -05:00
Benjamin Taylor 5042266824 fix(runtime): declare managed provider per-Channel instead of a hard-coded slack default (OSS-473) 2026-07-15 07:15:06 -05:00
Benjamin Taylor 6fb748c368 fix(runtime): report skipped direct-adapter channels as unmanaged, not healthy (OSS-473) 2026-07-15 07:15:05 -05:00
Benjamin Taylor decd4c20d5 fix(channels-intelligence): truthful connection-health for managed channels (OSS-473)
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.
2026-07-15 07:15:05 -05:00
Benjamin Taylor 496bb57e15 refactor(channels): rename telemetry surface oss.bot.* → oss.channel.* (OSS-473) 2026-07-15 07:15:05 -05:00
Benjamin Taylor 68349bc1f8 fix(runtime): non-optional handler.channels for intelligence runtimes with declared channels (OSS-473)
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.
2026-07-15 07:15:05 -05:00
Benjamin Taylor 08d14dcc8c fix(runtime): reachable setup_required + connection-health status from the real gateway (OSS-473)
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().
2026-07-15 07:15:05 -05:00
Benjamin Taylor 6ab2d57ff3 fix(runtime): skip managed activation for channels carrying a direct adapter (OSS-473)
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).
2026-07-15 07:15:05 -05:00
Benjamin Taylor 04a1b10e26 fix(runtime): safe-integer projectId, trim adapter, named error classes, RC7 guard test (OSS-473) 2026-07-15 07:15:04 -05:00
Benjamin Taylor 8ae942efce fix(runtime): remove drift-prone channel-name replica, log activation errors under err, cover default engine (OSS-473)
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.
2026-07-15 07:15:04 -05:00
Benjamin Taylor 6703232f4f fix(channels-intelligence): isolate onClose callbacks, guard safeReason, cover join-failure teardown (OSS-473) 2026-07-15 07:15:04 -05:00
Benjamin Taylor 040b1a848f fix(runtime): wire channel activation logging, redact key fully, validate channel-name format up-front (OSS-473)
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.
2026-07-15 07:15:04 -05:00
Benjamin Taylor 2c5c1138ea fix(channels-intelligence): reset onClose latch on rejoin so it fires once per drop (OSS-473) 2026-07-15 07:15:03 -05:00
Benjamin Taylor 98598bdd69 fix(runtime): idempotent channel teardown + activate-before-cache; optional peer dep for channels-intelligence (OSS-473)
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.
2026-07-15 07:15:03 -05:00
Benjamin Taylor 3c718faa08 fix(runtime): delegate channel reconnect to Phoenix; resilient stop; redact key in error (OSS-473)
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.
2026-07-15 07:15:03 -05:00
Benjamin Taylor 1f6446caca fix(channels-intelligence): preserve session receiver in onClose passthrough; clarify V1 text-turn comment (OSS-473) 2026-07-15 07:15:03 -05:00
Benjamin Taylor b0cb79f83e fix(runtime): fail-loud on duplicate channel names, non-hanging stop, reconnect ordering (OSS-473)
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.
2026-07-15 07:15:03 -05:00
Benjamin Taylor 7015837530 fix(runtime): declare channels-intelligence optional dep + export channels control types (OSS-473) 2026-07-15 07:15:02 -05:00
Benjamin Taylor 0e9f90cdba test(runtime): lock public handler.channels contract (OSS-473) 2026-07-15 07:15:02 -05:00
Benjamin Taylor 2e33d0cbbb test(runtime): integration proof of handler-owned channel activation (OSS-473) 2026-07-15 07:15:02 -05:00
Benjamin Taylor ea66c9328a feat(runtime): expose handler.channels through node/express/hono endpoints (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor b3081637c1 feat(runtime): handler owns managed channel activation and exposes handler.channels (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor 2a9b760c3a feat(channels-intelligence): expose onClose on managed session/handle for reconnect (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor a41da177de feat(runtime): supervised reconnect with bounded backoff for channels (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor 8f5185953f feat(runtime): add ChannelManager activation/readiness/stop lifecycle (OSS-473) 2026-07-15 07:15:01 -05:00
Benjamin Taylor b4f17e84b3 feat(runtime): derive channel activation config from intelligence config (OSS-473) 2026-07-15 07:15:00 -05:00
Benjamin Taylor 49c58bf081 feat(runtime): rename bots option to channels on the Intelligence runtime (OSS-473) 2026-07-15 07:15:00 -05:00
Benjamin Taylor c9f2119de3 refactor(channels-intelligence): make realtime scope org/channel ids optional (OSS-473) 2026-07-15 07:15:00 -05:00
Benjamin Taylor 229f3f1460 refactor(channels): rename Bot/createBot public API to Channel/createChannel across the workspace (OSS-473) 2026-07-15 07:14:59 -05:00
Alem Tuzlak 3adea157a4 test(channels-intelligence): cover getMessages mapping + address review
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).
2026-07-15 11:44:32 +02:00
Alem Tuzlak ecb91c8770 fix(channels-intelligence): teams thread history via getHistory + getMessages
getHistory was Slack-only: it keyed off threadTs and returned [] for any route
without one, so a Teams route ({adapter:'teams', tenantId, conversationId})
never hit /api/channels/history. That starved BOTH history paths on Teams --
agent.messages seeding (conversationStore.getOrCreate) and the read_thread tool
-- so the agent always reported "no earlier messages" / "no image in thread".

- 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. The /api/channels/history route
  already accepts this shape. Slack query order is 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 see
  nothing even when history exists.

Adds tests for the Teams getHistory query and the missing-id short-circuit.
2026-07-14 19:35:08 +02:00
Benjamin Taylor 2f72e5d720 fix(core): don't surface a user-initiated Stop as an agent error (#5966)
Follow-up to #5812/#5885. When the user presses Stop, an agent may emit a
terminal RUN_ERROR (often code "abort") as its cancellation signal. The client's
onRunErrorEvent surfaced that unconditionally via emitAgentError →
AGENT_RUN_ERROR_EVENT → the default chat error banner ("This operation was
aborted"), even though a user stop is expected, not a failure.

Suppress the error emission in onRunErrorEvent when the run was user-aborted,
mirroring the local-abort suppression already on the runAgent/connectAgent
paths. Prefer the client's own _runAbortController.signal.aborted (robust) over
the agent-supplied code, with code === "abort" as a secondary signal. Normal
RUN_ERRORs are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:14:29 -05:00
Ben Taylor 855446e1ab fix(runtime): stop finalizeRunEvents emitting events after a terminal (#5812) (#5885)
## Summary

Pressing **Stop** while an assistant message is streaming
(CopilotRuntime + `HttpAgent` proxy) crashed the chat with:

```
Cannot send event type 'TEXT_MESSAGE_END': The run has already errored with 'RUN_ERROR'. No further events can be sent.
```

Root cause: `finalizeRunEvents` appended a trailing `TEXT_MESSAGE_END`
**after** the `RUN_ERROR` that the aborted agent had already emitted.

Fixes #5812.

## Root cause

When the upstream agent (e.g. pydantic-ai's `AGUIAdapter`) is aborted
mid-stream it emits a live `RUN_ERROR` while a text message is still
open — it does **not** close the message first. All runners
(`in-memory`, `intelligence`, `sqlite`) stream `finalizeRunEvents`'
output *after* everything the agent already emitted, so the appended
closer landed past the terminal:

| | outgoing event order |
|---|---|
| **Before** | `… TEXT_MESSAGE_CONTENT → RUN_ERROR → TEXT_MESSAGE_END` ❌
verifier throws |
| **After** | `… TEXT_MESSAGE_CONTENT → RUN_ERROR` ✅ terminal closes the
message client-side |

Per the AG-UI invariant: at most one terminal event per run, and no
sub-events after it. I confirmed against the real `@ag-ui/client`
`verifyEvents` (the verifier the browser runs) that a terminal arriving
with a message still open is valid — the terminal implicitly closes it.

## Fix

`finalizeRunEvents` (in `@copilotkit/shared`) now returns early and
appends **nothing** when the stream already contains a terminal event
(`RUN_FINISHED` or `RUN_ERROR`). The abrupt-end path (no terminal →
close open streams + synthesize a terminal, in the correct order) is
unchanged. No API/signature change; the in-memory, intelligence, and
sqlite runners all inherit the fix.

## Testing

RED→GREEN verified — each new/updated assertion was confirmed to fail
against the pre-fix code:

- **`finalize-events.test.ts`** — terminal-present appends nothing
(parametrized over `RUN_FINISHED` and `RUN_ERROR`) + a named #5812 case.
- **`in-memory-runner.test.ts`** — end-to-end mid-stream-stop
regression: a fake `HttpAgent`-style agent is stopped between
`TEXT_MESSAGE_START` and `TEXT_MESSAGE_END`; asserts no events follow
`RUN_ERROR` **and** that the collected stream passes `verifyEvents`
(before the fix this threw the exact browser error).
- **`intelligence-runner.test.ts`** — corrected a pre-existing assertion
that had encoded the buggy post-terminal `TEXT_MESSAGE_END`.

Green: full `@copilotkit/runtime` suite, `@copilotkit/sqlite-runner`,
`@copilotkit/shared`, `check-types`, `oxlint` (0 errors), and build.

## Reviewer notes

- The behavior change is a single early-return in `finalize-events.ts`;
the `terminalEventMissing` guards simplify away because they're only
reachable when no terminal exists.
- Diff is +204/−55 across 4 files, the bulk of it tests.
2026-07-14 09:03:15 -05:00
Ben Taylor c266142354 fix(react-core): scope streamdown table action controls in packaged CSS (#5775) (#5944)
Fixes #5775.

## Problem

After #5099 scoped the streamdown markdown/table styles under
`[data-copilotkit] [data-streamdown="…"]`, the **table action controls**
(copy / download) are still unstyled for hosts that import
`@copilotkit/react-core/v2/styles.css` but don't also ship streamdown's
raw Tailwind utilities. streamdown renders the controls row, per-button
wrappers, trigger buttons, dropdown popovers and menu items with
unprefixed utilities (`flex`, `items-center`, `justify-end`, `gap-1`,
`cursor-pointer`, `p-1`, …) and **no stable `data-streamdown`
attribute**, so CopilotKit's packaged CSS didn't cover them — the
controls rendered as vertically stacked plain icons instead of a
right-aligned row.

## Fix

Add scoped fallback selectors under `[data-copilotkit]
[data-streamdown="table-wrapper"]`, targeting the controls chrome
**structurally** (since it has no `data-streamdown` hook):

- controls row → `> div:first-child:not(:last-child)` (flex,
right-aligned, gap)
- per-button wrapper → `… > div` (relative, positions the popover)
- trigger buttons → `… > div > button` (matches the code-block
copy/download button styling)
- dropdown popover → `… > div > div`
- popover menu items → `… > div > div > button`

The controls row is `table-wrapper`'s first child **only when controls
are enabled**; `:not(:last-child)` leaves a control-less table (whose
single child is the scroll container, already styled by #5099)
untouched.

## Verification

- **Compiles.** Built `globals.css` through the Tailwind v4 CLI — every
`@apply` resolves (e.g. `bg-background` → `var(--background)`,
`shadow-lg` → the shadow vars, `min-w-[120px]` → `min-width:120px`) and
all five rules emit with correct values.
- **Selectors match the real DOM.** A new DOM test
(`streamdown-table-controls.test.tsx`) renders a real `<Streamdown>`
table and asserts the controls row is `table-wrapper`'s first-non-only
child, carries no `data-streamdown` attribute, and contains the trigger
buttons under `> div > button` — i.e. the scoped selectors target real
elements. This also guards against streamdown markup drift.
- **Selector presence** guarded by `streamdown-styles.test.ts`
(whitespace-robust).
- Full `styles/__tests__` suite green; `oxlint`/`oxfmt` clean.

## Note (out of scope, discovered while fixing)

At runtime in streamdown `1.6.11` the `<table>` element is stamped
`data-streamdown="table-wrapper"` (not `"table"`): `MarkdownTable`
passes `data-streamdown="table-wrapper"` as a prop that leaks through
`...rest` onto the `<table>`, overriding the intended `"table"`. So
#5099's `[data-streamdown="table"]` selector currently matches nothing,
and the table also matches the `table-wrapper` rules. The controls fix
here is unaffected (its child-combinator selectors don't match the
table's `thead`/`tbody` children), but the `[data-streamdown="table"]`
selector is worth a separate follow-up / upstream report.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-13 17:23:33 -05:00
Benjamin Taylor 5b7eccf464 fix(react-core): drop stale MCP follow-ups instead of re-homing them (OSS review)
Addresses two P1s on #5940: the re-home path (a) injected unscoped frontend
tools/readable context into the background run — `buildFrontendTools`/
`getContextForAgent` include entries with no agentId, so the thread-A run
received thread-B's live context and could execute global frontend handlers
against B — and (b) lost continuity across multiple queued follow-ups (fresh
per-item proxy + enqueue-time snapshot, so run 2 never saw run 1's result).

Both stem from *running* the stale follow-up. Switch to skip-stale: when the
shared agent's threadId no longer matches the thread the follow-up was enqueued
for, drop it (with a warning) rather than run it against the now-foreground
thread. This removes the proxy/registerProxiedAgent machinery entirely and
resolves both P1s by construction. The MCP app still gets its ui/message ack at
enqueue time; only the optional agent turn on an abandoned thread is skipped.

Removes the re-home unit/integration tests; the e2e regression test (no
cross-thread run after a switch) and simplified unit tests cover the behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:01:30 -05:00
Benjamin Taylor 3be17829cc test(react-core): integration coverage for MCP follow-up re-home path (#5819)
Drives the real CopilotKitCore + RunHandler + ProxiedCopilotRuntimeAgent via
registerProxiedAgent against a mocked transport (mirrors
proxied-runtime-transport.test.ts). Asserts the re-homed run reaches the runtime
addressed to the ORIGINAL threadId (not the foreground one), carries the captured
message, runs on an isolated instance whose events never reach the shared agent,
and unregisters the transient proxy after. Closes the delegate/replay-lifecycle
gap the mocked-host unit tests couldn't reach — in CI, no live runtime needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:01:30 -05:00
Benjamin Taylor a1dd26f0b4 fix(react-core): scope queued MCP ui/message follow-ups to enqueue-time thread (#5819)
Queued MCP app ui/message follow-up work executed against whatever thread the
shared registry agent pointed at when the queue drained. If the host switched
threads while the follow-up was queued (agent busy), the run — and its streamed
events — leaked into the now-foreground thread.

Capture the thread context at enqueue and route the follow-up through
ɵrunMcpFollowUp: same thread runs live on the shared agent (unchanged); a
changed thread re-homes the run onto an isolated registerProxiedAgent sibling
pinned to the original thread (own event stream, persists + reconciles on
return); a changed thread on a non-runtime agent drops the follow-up rather
than leaking it.

Regression from 762370a4e5 (revert of per-thread activity-renderer clone
routing, #3630); uses the sanctioned registerProxiedAgent primitive (#4629)
instead of reintroducing implicit clones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:01:30 -05:00
github-actions[bot] b693416bb6 style: auto-fix formatting 2026-07-13 16:58:26 -05:00
Benjamin Taylor 2882cdad92 fix(react-core): scope streamdown table action controls in packaged CSS (#5775)
After #5099 the table wrapper/cells are styled via [data-streamdown] selectors,
but the copy/download controls row, button wrappers, trigger buttons, dropdown
popovers and menu items render with raw Tailwind utilities and no stable
data-streamdown attribute — so hosts that import @copilotkit/react-core/v2
styles without shipping streamdown's own utilities saw them unstyled (icons
stacked vertically instead of a right-aligned row).

Add scoped fallback selectors under [data-copilotkit] [data-streamdown=
"table-wrapper"], targeting the controls chrome structurally. The controls row
is the first child ONLY when controls are enabled, so :not(:last-child) leaves a
control-less table (single child = the scroll container) untouched.

Verified against streamdown 1.6.11's actual rendered DOM (a new DOM test guards
that the structure the selectors assume still holds) and by compiling the CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:58:26 -05:00
Benjamin Taylor 690108bfd6 test(react-core): strengthen #5801 provider coverage to actually catch ctor-fetch
Adversarial review found the original provider tests passed even with the fix
removed: a single committed mount yields exactly one /info whether the ctor or
an effect fires it, and the ctor's fetch is several microtasks deep so ordering
can't distinguish it — only the multi-instance (discarded-render) case differs,
which Testing Library can't reproduce.

- Add CopilotKitProvider.deferWiring.test.tsx (mocked core): asserts the provider
  constructs with `deferInitialConnection: true` and calls `connect()` from an
  effect. This FAILS if the deferral wiring is dropped (verified).
- Keep the two real-core tests as normal-mount regression guards (one /info on
  mount; idempotent under StrictMode) and document that the multi-instance proof
  lives in core-defer-runtime-connection.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:21:22 -05:00
github-actions[bot] 6ab7e9d3d6 style: auto-fix formatting 2026-07-13 16:21:22 -05:00
Benjamin Taylor ce2d52397d fix(core,react-core): defer runtime /info out of the core constructor (#5801)
CopilotKitProvider constructs the core during React's render phase, and React
can start-and-discard renders (concurrent rendering / Suspense / StrictMode).
Because the constructor fired the `/info` request synchronously, every discarded
-and-recreated core issued its own request — a single page load was observed
firing 70-80 `/info` requests instead of one.

Separate construction (pure) from connection (network I/O):

- core: `deferInitialConnection` lets the constructor record the runtime config
  (so `runtimeUrl` stays available synchronously to hooks) WITHOUT starting the
  `/info` fetch. `connect()` starts the single connection and is idempotent
  (bails unless status is Disconnected), so a double-invoked mount effect
  collapses to one request. `updateRuntimeConnection` also gains an in-flight
  guard keyed by url+transport so concurrent same-target calls de-dupe.
- react-core: the provider constructs with `deferInitialConnection: true` and
  calls `copilotkit.connect()` from its commit-phase mount effect — so renders
  discarded before commit never fetch.

Backward compatible: without `deferInitialConnection` the constructor still
connects (Vue/Angular/vanilla unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:21:22 -05:00